Skip to content

Tabs

A flexible tabs component for organizing content into switchable panels. Keyboard accessible, animation-ready, and available in six visual styles.

Features

  • 6 Variants: solid, flat, bordered, ghost, glass, frost
  • 7 Colors: primary, secondary, info, success, warning, error (+ neutral default)
  • 3 Sizes: sm, md, lg
  • Accessible: Full ARIA roles (tablist, tab, tabpanel), keyboard navigation
  • Panel Transitions: Fade + slide-up animation on panel reveal
  • Composable: Three separate elements — sg-tabs, sg-tab-item, sg-tab-panel

Source Code

View Source Code (sg-tabs)
ts
import { createContext, define, html, prop, ref } from '@vielzeug/craft';
import { computed, type ReadonlySignal, signal, watch } from '@vielzeug/ripple';

import type { ComponentSize, ThemeColor, VisualVariant } from '../../types';

import { createInteraction, createListControl } from '../../headless';
import { sizableBundle, themableBundle } from '../../shared';
import { colorThemeMixin } from '../../styles';
import styles from './tabs.css?inline';

/** Context provided by sg-tabs to its sg-tab-item and sg-tab-panel children. */
export type TabsContext = {
  color: ReadonlySignal<ThemeColor | undefined>;
  orientation: ReadonlySignal<'horizontal' | 'vertical'>;
  size: ReadonlySignal<ComponentSize | undefined>;
  value: ReadonlySignal<string | undefined>;
  variant: ReadonlySignal<VisualVariant | undefined>;
};
/** Injection key for the tabs context. */
export const TABS_CTX = createContext<TabsContext>('TabsContext');

export type SgTabsEvents = {
  change: { value: string };
};

export type SgTabsProps = {
  /**
   * Keyboard activation mode.
   * - `'auto'` (default): Selecting a tab on arrow-key focus immediately activates it (ARIA recommendation for most cases).
   * - `'manual'`: Arrow keys only move focus; the user must press Enter or Space to activate the focused tab.
   */
  activation?: 'auto' | 'manual';
  /** Theme color */
  color?: ThemeColor;
  /** Accessible label for the tablist (passed as aria-label). Use when there is no visible heading labelling the tabs. */
  label?: string;
  /** Tab list orientation */
  orientation?: 'horizontal' | 'vertical';
  /** Component size */
  size?: ComponentSize;
  /** Currently selected tab value */
  value?: string;
  /** Visual style variant */
  variant?: VisualVariant;
};

/**
 * Tabs container. Manages tab selection and syncs state to child tab items and panels.
 *
 * @element sg-tabs
 * @element sg-tab-item - Child element for tab buttons (auto-discovered)
 * @element sg-tab-panel - Child element for tab content (auto-discovered)
 *
 * @attr {string} value - The value of the currently selected tab
 * @attr {string} variant - Visual variant: 'solid' | 'flat' | 'bordered' | 'ghost' | 'glass' | 'frost'
 * @attr {string} size - Size: 'sm' | 'md' | 'lg'
 * @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
 *
 * @fires change - Emitted when the active tab changes with detail: { value: string }
 *
 * @slot tabs - Place `sg-tab-item` elements here
 * @slot - Place `sg-tab-panel` elements here
 *
 * @cssprop --tabs-radius - Border radius of the tablist container and panels
 * @cssprop --tabs-transition - Transition duration/easing for the active indicator
 * @cssprop --tabs-indicator-color - Color of the sliding active indicator line
 * @cssprop --tabs-bg - Background of the host element (flat variant)
 * @cssprop --tabs-tablist-bg - Tablist container background (solid/glass/frost variants)
 * @cssprop --tabs-tablist-border-color - Tablist container border color
 * @part tablist - Container that holds the slotted tab items
 * @part indicator - Active tab indicator element
 * @part panels - Container that holds tab panel content
 * @example
 * ```html
 * <sg-tabs value="tab1" variant="underline">
 *   <sg-tab-item slot="tabs" value="tab1">Overview</sg-tab-item>
 *   <sg-tab-item slot="tabs" value="tab2">Settings</sg-tab-item>
 *   <sg-tab-panel value="tab1"><p>Overview content</p></sg-tab-panel>
 *   <sg-tab-panel value="tab2"><p>Settings content</p></sg-tab-panel>
 * </sg-tabs>
 * ```
 */
export const TABS_TAG = 'sg-tabs' as const;
define<SgTabsProps, SgTabsEvents>(TABS_TAG, {
  props: {
    ...themableBundle,
    ...sizableBundle,
    activation: prop.oneOf(['auto', 'manual'] as const, 'auto'),
    label: prop.string(),
    orientation: prop.oneOf(['horizontal', 'vertical'] as const, 'horizontal'),
    value: prop.string(),
    variant: prop.string<VisualVariant>(),
  },
  setup(props, { bind, el, emit, onMounted, provide }) {
    const shadowRoot = el.shadowRoot;
    const tablistRef = ref<HTMLElement>();
    const indicatorRef = ref<HTMLElement>();
    const selectedValue = signal<string | undefined>(props.value.value);
    const isManualActivation = () => props.activation.value === 'manual';
    const isVertical = () => props.orientation.value === 'vertical';

    bind({
      attr: {
        value: () => selectedValue.value ?? null,
      },
    });

    const getTabs = () => [...el.querySelectorAll<HTMLElement>(':scope > sg-tab-item[slot="tabs"]')];
    const getEnabledTabs = () => getTabs().filter((t) => !t.hasAttribute('disabled'));
    const focusTab = (tab: HTMLElement | undefined) => {
      if (!tab) return;

      const focusable = tab.shadowRoot?.querySelector<HTMLElement>('[role="tab"]') ?? tab;

      focusable.focus();
    };

    // ────────────────────────────────────────────────────────────────
    // Selection State Management
    // ────────────────────────────────────────────────────────────────

    const setSelection = (value: string | undefined, shouldEmit = false) => {
      selectedValue.value = value;

      if (shouldEmit && value) emit('change', { value });
    };

    const ensureSelection = () => {
      const tabs = getTabs();

      if (tabs.length === 0) return; // No tabs yet, keep current selection

      const current = selectedValue.value;
      const hasCurrent = current
        ? tabs.some((tab) => tab.getAttribute('value') === current && !tab.hasAttribute('disabled'))
        : false;

      if (hasCurrent) return;

      const firstEnabled = tabs.find((tab) => !tab.hasAttribute('disabled'))?.getAttribute('value') ?? undefined;

      setSelection(firstEnabled, false);
    };

    watch(props.value, (value) => {
      selectedValue.value = value;
      ensureSelection();
    });

    // ────────────────────────────────────────────────────────────────
    // List Control for Keyboard Navigation
    // ────────────────────────────────────────────────────────────────

    const listControl = createListControl({
      getItems: () => getEnabledTabs(),
      isItemDisabled: (tab: HTMLElement) => tab.hasAttribute('disabled'),
      loop: true,
      onNavigate: (_action, index) => {
        const tabs = getEnabledTabs();
        const nextTab = tabs[index];

        focusTab(nextTab);

        if (!isManualActivation()) {
          const value = nextTab?.getAttribute('value');

          if (value) setSelection(value, true);
        }
      },
      orientation: () => (isVertical() ? 'vertical' : 'both'),
    });

    // ────────────────────────────────────────────────────────────────
    // Context & Indicator Management
    // ────────────────────────────────────────────────────────────────

    provide(TABS_CTX, {
      color: props.color,
      orientation: computed(() => props.orientation.value ?? 'horizontal'),
      size: props.size,
      value: selectedValue,
      variant: props.variant,
    });

    const moveIndicator = (activeTab: HTMLElement | undefined) => {
      const indicator = indicatorRef.value;
      const tablist = tablistRef.value;

      if (!indicator || !tablist || !activeTab) return;

      const tabRect = activeTab.getBoundingClientRect();
      const listRect = tablist.getBoundingClientRect();

      if (isVertical()) {
        const y = tabRect.top - listRect.top + tablist.scrollTop;

        indicator.style.transition = 'none';
        indicator.style.height = `${tabRect.height}px`;
        indicator.style.width = '';
        indicator.style.left = '0';
        indicator.style.top = '0';
        void indicator.offsetWidth;
        indicator.style.transition = '';
        indicator.style.transform = `translateY(${y}px)`;
      } else {
        const x = tabRect.left - listRect.left + tablist.scrollLeft;

        indicator.style.transition = 'none';
        indicator.style.width = `${tabRect.width}px`;
        indicator.style.height = '';
        indicator.style.top = '';
        indicator.style.left = '0';
        void indicator.offsetWidth;
        indicator.style.transition = '';
        indicator.style.transform = `translateX(${x}px)`;
      }
    };

    const updateIndicator = () => {
      const value = selectedValue.value;

      if (!value) return;

      const activeTab = getTabs().find((t) => t.getAttribute('value') === value);

      moveIndicator(activeTab);
    };

    watch(selectedValue, () => {
      requestAnimationFrame(updateIndicator);
    });

    // ────────────────────────────────────────────────────────────────
    // Event Handlers
    // ────────────────────────────────────────────────────────────────

    const handleTabClick = (e: Event) => {
      const tab = e
        .composedPath()
        .find((node): node is HTMLElement => node instanceof HTMLElement && node.localName === 'sg-tab-item');

      if (!tab || tab.hasAttribute('disabled')) return;

      // Guard: only respond to tab-items that belong to THIS tabs instance
      if (tab.closest('sg-tabs') !== el) return;

      const value = tab.getAttribute('value');

      if (!value || value === selectedValue.value) return;

      setSelection(value, true);
    };

    const activateFocusedTab = (): void => {
      const tabs = getEnabledTabs();
      const focusedTab = tabs.find(
        (tab) => tab === document.activeElement || tab.shadowRoot?.activeElement === document.activeElement,
      );
      const focusedValue = focusedTab?.getAttribute('value');

      if (focusedValue && focusedValue !== selectedValue.value) setSelection(focusedValue, true);
    };

    const manualActivationPress = createInteraction({
      disabled: () => !isManualActivation(),
      onPress: activateFocusedTab,
    });

    const handleKeydown = (e: KeyboardEvent) => {
      const tabs = getEnabledTabs();

      if (tabs.length === 0) return;

      const path = e.composedPath();
      const activeTabFromEvent = path.find(
        (node): node is HTMLElement => node instanceof HTMLElement && node.localName === 'sg-tab-item',
      );

      const tabFromEvent = activeTabFromEvent ?? tabs.find((t) => t.getAttribute('value') === selectedValue.value);
      const focused = tabFromEvent ? tabs.indexOf(tabFromEvent) : -1;

      if (focused >= 0) listControl.set(focused);

      if (listControl.handleKeydown(e)) return;

      manualActivationPress.handleKeydown(e);
    };

    bind({
      on: {
        click: handleTabClick,
        keydown: handleKeydown,
      },
    });

    // ────────────────────────────────────────────────────────────────
    // Lifecycle
    // ────────────────────────────────────────────────────────────────

    onMounted(() => {
      const syncSelection = () => {
        ensureSelection();
        updateIndicator();
      };

      const tabsSlot = shadowRoot?.querySelector<HTMLSlotElement>('slot[name="tabs"]');

      if (tabsSlot) {
        tabsSlot.addEventListener('slotchange', syncSelection);
      }

      syncSelection();
      requestAnimationFrame(syncSelection);

      return () => {
        if (tabsSlot) {
          tabsSlot.removeEventListener('slotchange', syncSelection);
        }
      };
    });

    return html`
      <div class="tablist-wrapper">
        <div
          role="tablist"
          ref="${tablistRef}"
          part="tablist"
          aria-orientation="${props.orientation}"
          aria-label="${props.label}">
          <slot name="tabs"></slot>
        </div>
        <div class="indicator" ref="${indicatorRef}" part="indicator"></div>
      </div>
      <div class="panels" part="panels">
        <slot></slot>
      </div>
    `;
  },
  styles: [colorThemeMixin, styles],
});
View Source Code (sg-tab-item)
ts
import { define, html, inject, prop } from '@vielzeug/craft';
import { computed } from '@vielzeug/ripple';

import type { ComponentSize, ThemeColor, VisualVariant } from '../../types';

import { coarsePointerMixin, colorThemeMixin, forcedColorsFocusMixin } from '../../styles';
import { TABS_CTX } from '../tabs/tabs';
import styles from './tab-item.css?inline';

export type SgTabItemProps = {
  /** Whether this tab is currently selected (set by sg-tabs) */
  active?: boolean;
  /** Theme color (inherited from sg-tabs) */
  color?: ThemeColor;
  /** Disable this tab */
  disabled?: boolean;
  /** Size (inherited from sg-tabs) */
  size?: ComponentSize;
  /** Unique value identifier — must match a sg-tab-panel value */
  value: string;
  /** Visual variant (inherited from sg-tabs) */
  variant?: VisualVariant;
};

/**
 * Individual tab trigger. Must be placed in the `tabs` slot of `sg-tabs`.
 *
 * @element sg-tab-item
 *
 * @attr {string} value - Unique identifier, matches the corresponding sg-tab-panel value
 * @attr {boolean} active - Set by the parent sg-tabs when this tab is selected
 * @attr {boolean} disabled - Prevents selection
 * @attr {string} size - 'sm' | 'md' | 'lg'
 * @attr {string} variant - Inherited from sg-tabs: 'solid' | 'flat' | 'bordered' | 'ghost' | 'glass' | 'frost' | 'underline'
 * @attr {string} color - Inherited from sg-tabs: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
 *
 * @slot prefix - Icon or content before the label
 * @slot - Tab label
 * @slot suffix - Badge or count after the label
 *
 * @cssprop --tab-item-radius - Button border radius
 * @cssprop --tab-item-transition - Transition duration/easing
 * @cssprop --tab-item-padding - Button padding
 * @cssprop --tab-item-font-size - Button font size
 * @cssprop --tab-item-color - Default text color
 * @cssprop --tab-item-hover-bg - Background on hover
 * @cssprop --tab-item-active-bg - Background when active/selected
 * @cssprop --tab-item-active-color - Text color when active/selected
 * @cssprop --tab-item-active-shadow - Box shadow when active/selected
 * @part tab - Tab trigger element.
 * @example
 * ```html
 * <sg-tab-item slot="tabs" value="overview">Overview</sg-tab-item>
 * <sg-tab-item slot="tabs" value="settings" disabled>Settings</sg-tab-item>
 * ```
 */
export const TAB_ITEM_TAG = 'sg-tab-item' as const;
define<SgTabItemProps>(TAB_ITEM_TAG, {
  props: {
    active: prop.bool(false),
    color: prop.string<ThemeColor>(),
    disabled: prop.bool(false),
    size: prop.string<ComponentSize>(),
    value: prop.string(''),
    variant: prop.string<VisualVariant>(),
  },
  setup(props, { bind, el, watch }) {
    const tabsCtx = inject(TABS_CTX);

    if (tabsCtx) {
      watch(() => {
        const color = tabsCtx.color.value;
        const size = tabsCtx.size.value;
        const variant = tabsCtx.variant.value;

        if (color !== undefined) el.setAttribute('color', color);

        if (size !== undefined) el.setAttribute('size', size);

        if (variant !== undefined) el.setAttribute('variant', variant);
      });
    }

    const isActive = computed(() =>
      tabsCtx ? !!tabsCtx.value.value && tabsCtx.value.value === props.value.value : props.active.value,
    );
    const isDisabled = () => Boolean(props.disabled.value);

    bind({
      attr: {
        active: () => (isActive.value ? true : undefined),
      },
    });

    const handleClick = (event: MouseEvent) => {
      event.stopPropagation();

      if (isDisabled()) {
        event.preventDefault();

        return;
      }

      el.dispatchEvent(new CustomEvent('click', { bubbles: true, detail: { value: props.value.value } }));
    };

    const tabId = () => `tab-${props.value.value}`;
    const controlsAttr = () => `tabpanel-${props.value.value}`;

    return html`
      <button
        role="tab"
        type="button"
        part="tab"
        :id="${tabId}"
        aria-selected="${isActive}"
        tabindex="${() => (isActive.value ? '0' : '-1')}"
        aria-disabled="${isDisabled}"
        :aria-controls="${controlsAttr}"
        @click="${handleClick}">
        <slot name="prefix"></slot>
        <slot></slot>
        <slot name="suffix"></slot>
      </button>
    `;
  },
  styles: [colorThemeMixin, forcedColorsFocusMixin('button'), coarsePointerMixin, styles],
});
View Source Code (sg-tab-panel)
ts
import { define, html, inject, prop, styleMap, when } from '@vielzeug/craft';
import { computed, signal } from '@vielzeug/ripple';

import { reducedMotionMixin } from '../../styles';
import { TABS_CTX } from '../tabs/tabs';
import styles from './tab-panel.css?inline';

const TAB_PANEL_PADDING_PRESET: Record<string, string> = {
  '2xl': 'var(--size-12)',
  lg: 'var(--size-6)',
  md: 'var(--size-4)',
  none: '0',
  sm: 'var(--size-2)',
  xl: 'var(--size-8)',
  xs: 'var(--size-1)',
};

export type SgTabPanelProps = {
  /** Active state (managed by sg-tabs) */
  active?: boolean;
  /** When true, the panel content is not rendered until first activation (preserves resources) */
  lazy?: boolean;
  /** Panel padding size: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' (default: 'md' = var(--size-4)) */
  padding?: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
  /** Must match the `value` of its corresponding sg-tab-item */
  value: string;
};

/**
 * Content panel for a tab. Shown when its `value` matches the selected tab.
 *
 * @element sg-tab-panel
 *
 * @attr {string} value - Must match the corresponding sg-tab-item value
 * @attr {boolean} active - Toggled by the parent sg-tabs
 * @attr {string} padding - Panel padding: 'none' | 'xs' | 'sm' | 'md' (default) | 'lg' | 'xl' | '2xl'
 *
 * @slot - Panel content
 *
 * @cssprop --tab-panel-padding - Inner padding of the panel content area
 * @cssprop --tab-panel-font-size - Font size of the panel content
 * @part panel - Panel container.
 * @example
 * ```html
 * <sg-tab-panel value="overview"><p>Overview content here</p></sg-tab-panel>
 * <sg-tab-panel value="settings" padding="lg"><p>Large padding</p></sg-tab-panel>
 * <sg-tab-panel value="code" padding="none"><pre>No padding for code</pre></sg-tab-panel>
 * ```
 */
export const TAB_PANEL_TAG = 'sg-tab-panel' as const;
define<SgTabPanelProps>(TAB_PANEL_TAG, {
  props: {
    active: prop.bool(false),
    lazy: prop.bool(false),
    padding: prop.oneOf(['none', 'xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const, 'md'),
    value: prop.string(''),
  },
  setup(props, { bind, watch }) {
    const tabsCtx = inject(TABS_CTX);
    const isActive = computed(() =>
      tabsCtx ? !!tabsCtx.value.value && tabsCtx.value.value === props.value.value : props.active.value,
    );

    bind({
      attr: {
        active: () => (isActive.value ? true : undefined),
      },
    });

    // Map padding prop to CSS variable
    const paddingValue = computed(() => {
      const key = props.padding.value ?? 'md';

      return TAB_PANEL_PADDING_PRESET[key] ?? TAB_PANEL_PADDING_PRESET.md;
    });
    // Track whether the panel has ever been active (for lazy rendering)
    const hasBeenActive = signal(false);

    watch(() => {
      if (isActive.value) hasBeenActive.value = true;
    });

    // shouldRender: true if not lazy OR has been active at least once
    const shouldRender = computed(() => !props.lazy.value || hasBeenActive.value);
    const panelStyle = styleMap({ '--tab-panel-padding': paddingValue });
    const panelId = () => `tabpanel-${props.value.value}`;
    const labelledById = () => `tab-${props.value.value}`;

    return html`
      <div
        class="panel"
        part="panel"
        role="tabpanel"
        id="${() => panelId()}"
        aria-labelledby="${() => labelledById()}"
        aria-hidden="${() => String(!isActive.value)}"
        :style="${panelStyle}"
        tabindex="0">
        ${when(shouldRender, () => html`<slot></slot>`)}
      </div>
    `;
  },
  styles: [reducedMotionMixin, styles],
});

Basic Usage

html
<sg-tabs value="overview">
  <sg-tab-item slot="tabs" value="overview">Overview</sg-tab-item>
  <sg-tab-item slot="tabs" value="settings">Settings</sg-tab-item>
  <sg-tab-item slot="tabs" value="billing">Billing</sg-tab-item>

  <sg-tab-panel value="overview"><p>Overview content.</p></sg-tab-panel>
  <sg-tab-panel value="settings"><p>Settings content.</p></sg-tab-panel>
  <sg-tab-panel value="billing"><p>Billing content.</p></sg-tab-panel>
</sg-tabs>

Visual Options

Variants

Solid (Default)

Pill-style tabs in a rounded container — clean and contained.

PreviewCode
RTL

Flat

Tabs and panel share a single container background — they read as one unified block.

PreviewCode
RTL

Bordered

Tabs that visually connect to their panel with a shared border.

PreviewCode
RTL

Ghost

Open tabs with a filled active pill — no container border, floats freely.

PreviewCode
RTL

Glass & Frost Variants

Translucent tab bars with backdrop blur — best used over rich backgrounds.

Best Used With

Glass and frost variants look best over colorful backgrounds or images to showcase the blur and transparency effects.

PreviewCode
RTL

Colors

Set color on sg-tabs to apply a theme color — it propagates automatically to all tab items. The color drives the active pill fill on ghost, the focus ring on all variants, and the indicator line on future underline-style usage.

PreviewCode
RTL

Sizes

PreviewCode
RTL

Vertical Tabs

Use orientation="vertical" to place the tab list on the side. This works well for settings pages, account sections, or docs-style navigation.

PreviewCode
RTL

Vertical + Manual Activation

For keyboard-heavy interfaces, pair vertical tabs with activation="manual" so arrow keys move focus and Enter/Space commits selection.

PreviewCode
RTL

Customization

Icons & Badges

Use the prefix and suffix slots on sg-tab-item to add icons or notification badges.

PreviewCode
RTL

States

Lazy Panels

Add lazy to a sg-tab-panel to defer rendering its slot content until the tab is first activated. Once activated, the content stays rendered even if the tab is later switched away. This is useful for panels containing expensive components or data-fetching logic.

html
<sg-tabs value="tab1">
  <sg-tab-item slot="tabs" value="tab1">Quick</sg-tab-item>
  <sg-tab-item slot="tabs" value="tab2">Heavy</sg-tab-item>

  <sg-tab-panel value="tab1"><p>Rendered immediately.</p></sg-tab-panel>
  <sg-tab-panel value="tab2" lazy>
    <!-- Only rendered after the "Heavy" tab is first clicked -->
    <my-heavy-component></my-heavy-component>
  </sg-tab-panel>
</sg-tabs>

Disabled Tabs

Prevent specific tabs from being selected.

PreviewCode
RTL

Keyboard Navigation

KeyAction
ArrowRightMove to the next tab (wraps around)
ArrowLeftMove to the previous tab (wraps around)
HomeJump to the first tab
EndJump to the last tab

Disabled tabs are skipped during keyboard navigation.

API Reference

sg-tabs Attributes

AttributeTypeDefaultDescription
valuestringValue of the currently selected tab
variant'solid' | 'flat' | 'bordered' | 'ghost' | 'glass' | 'frost''solid'Visual style of the tab bar
size'sm' | 'md' | 'lg''md'Size applied to all tab items
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'Theme color propagated to all tab items
orientation'horizontal' | 'vertical''horizontal'Tab list layout direction
activation'auto' | 'manual''auto'auto: arrow keys select immediately; manual: Enter/Space confirms

sg-tabs Events

EventDetailDescription
change{ value: string }Fired when the active tab changes

sg-tabs Slots

SlotDescription
tabsPlace sg-tab-item elements here
(default)Place sg-tab-panel elements here

sg-tab-item Attributes

AttributeTypeDefaultDescription
valuestringRequired. Must match the corresponding sg-tab-panel value
activebooleanfalseWhether this tab is selected (managed by sg-tabs)
disabledbooleanfalsePrevents the tab from being selected
size'sm' | 'md' | 'lg'inheritedInherited from parent sg-tabs
variantstringinheritedInherited from parent sg-tabs
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'inheritedInherited from parent sg-tabs

sg-tab-item Slots

SlotDescription
prefixIcon or content before the label
(default)Tab label text
suffixBadge or count after the label

sg-tab-panel Attributes

AttributeTypeDefaultDescription
valuestringRequired. Must match the corresponding sg-tab-item value
activebooleanfalseWhether this panel is visible (managed by sg-tabs)
lazybooleanfalseDefer rendering slot content until the panel is first activated

CSS Custom Properties

PropertyDefaultDescription
--tabs-radiusvar(--rounded-lg)Border radius of the tablist container and panels
--tabs-transitionvar(--transition-normal)Transition duration/easing for the active indicator
--tabs-indicator-colorTheme colorColor of the sliding active indicator line
--tabs-bgTheme-dependentBackground of the host element (flat variant)
--tabs-tablist-bgTheme-dependentTablist container background (solid/glass/frost variants)
--tabs-tablist-border-colorTheme-dependentTablist container border color
--tab-panel-paddingvar(--size-4)Padding inside each tab panel
--tab-item-radiusTheme-dependentTab button border radius
--tab-item-colorTheme-dependentDefault tab text color
--tab-item-hover-bgTheme-dependentTab background on hover
--tab-item-active-bgTheme-dependentTab background when active/selected
--tab-item-active-colorTheme-dependentTab text color when active/selected
--tab-item-active-shadowTheme-dependentBox shadow when active/selected

Accessibility

The tabs component follows the WAI-ARIA Tabs Pattern best practices.

sg-tabs

Keyboard Navigation
  • ArrowRight / ArrowLeft navigate between tabs; Home / End jump to first / last.
  • Disabled tabs are skipped during keyboard navigation.
Screen Readers
  • The tab list has role="tablist".
  • Each tab has role="tab" with aria-selected and aria-controls pointing to its panel.
  • Each panel has role="tabpanel" with aria-labelledby pointing to its tab.
  • Disabled tabs have aria-disabled="true".

Best Practices

Do:

  • Keep tab labels short and descriptive (ideally 1–3 words).
  • Always set a default value on sg-tabs so a tab is active on first render.
  • Use the prefix and suffix slots on sg-tab-item to add icons or notification counts.
  • Use variant="bordered" or variant="flat" when tabs need to feel visually connected to the panel content below them.

Don't:

  • Use more than 5–7 tabs — consider a sidebar navigation for larger sets of sections.
  • Use tabs to represent sequential steps; use a stepper component for linear flows.
  • Nest tabs inside tabs — it creates confusing navigation hierarchies.