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 —
bit-tabs,bit-tab-item,bit-tab-panel
Source Code
View Source Code (bit-tabs)
import {
computed,
createContext,
define,
html,
prop,
provide,
type ReadonlySignal,
ref,
signal,
watch,
onMounted,
} from '@vielzeug/craftit';
import { createListControl, createPressControl } from '@vielzeug/craftit/controls';
import type { ComponentSize, ThemeColor, VisualVariant } from '../../types';
import { sizableBundle, themableBundle } from '../../inputs/shared/bundles';
import { colorThemeMixin } from '../../styles';
import styles from './tabs.css?inline';
/** Context provided by bit-tabs to its bit-tab-item and bit-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 BitTabsEvents = {
change: { value: string };
};
export type BitTabsProps = {
/**
* 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 bit-tabs
* @element bit-tab-item - Child element for tab buttons (auto-discovered)
* @element bit-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 `bit-tab-item` elements here
* @slot - Place `bit-tab-panel` elements here
*
* @cssprop --blur-lg - Backdrop blur for frosted tab variants
* @cssprop --border - Border token used by tablist and panel separators
* @cssprop --color-canvas - Base background color for tab surfaces
* @cssprop --color-contrast-100 - Hover background tone for tab items
* @cssprop --color-contrast-200 - Border/divider contrast tone
* @cssprop --color-contrast-300 - Muted contrast tone for inactive states
* @cssprop --color-secondary - Accent color for active tab highlights
* @cssprop --color-secondary-contrast - Text/icon color on active tab accents
* @cssprop --inset-shadow-xs - Inset shadow used by bordered tab variants
* @cssprop --rounded-full - Pill-style radius for rounded tab variants
* @cssprop --rounded-lg - Radius for tablist container and panels
* @cssprop --shadow-2xs - Subtle shadow for layered tab surfaces
* @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
* <bit-tabs value="tab1" variant="underline">
* <bit-tab-item slot="tabs" value="tab1">Overview</bit-tab-item>
* <bit-tab-item slot="tabs" value="tab2">Settings</bit-tab-item>
* <bit-tab-panel value="tab1"><p>Overview content</p></bit-tab-panel>
* <bit-tab-panel value="tab2"><p>Settings content</p></bit-tab-panel>
* </bit-tabs>
* ```
*/
export const TABS_TAG = define<BitTabsProps, BitTabsEvents>('bit-tabs', {
props: {
...themableBundle,
...sizableBundle,
activation: prop.oneOf(['auto', 'manual'] as const, 'auto'),
label: undefined,
orientation: prop.oneOf(['horizontal', 'vertical'] as const, 'horizontal'),
value: { default: undefined as string | undefined, reflect: false }, // managed by host.bind (selectedValue derived state)
variant: undefined,
},
setup(props, { emit, host }) {
const shadowRoot = host.el.shadowRoot;
const tablistRef = ref<HTMLElement>();
const indicatorRef = ref<HTMLElement>();
const selectedValue = signal<string | undefined>(props.value.value);
const focusedIndex = signal(0);
const isManualActivation = () => props.activation.value === 'manual';
const isVertical = () => props.orientation.value === 'vertical';
host.bind({
attr: {
value: () => selectedValue.value ?? null,
},
});
const getTabs = () => [...host.el.querySelectorAll<HTMLElement>(':scope > bit-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({
getIndex: () => focusedIndex.value,
getItems: () => getEnabledTabs(),
isItemDisabled: (tab: HTMLElement) => tab.hasAttribute('disabled'),
keys: () => {
if (isVertical()) {
return {
next: ['ArrowDown'],
prev: ['ArrowUp'],
};
}
return {
next: ['ArrowRight', 'ArrowDown'],
prev: ['ArrowLeft', 'ArrowUp'],
};
},
loop: true,
setIndex: (index) => {
focusedIndex.value = index;
const tabs = getEnabledTabs();
const nextTab = tabs[index];
focusTab(nextTab);
if (!isManualActivation()) {
const value = nextTab?.getAttribute('value');
if (value) setSelection(value, true);
}
},
});
// ────────────────────────────────────────────────────────────────
// 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()) {
indicator.style.top = `${tabRect.top - listRect.top + tablist.scrollTop}px`;
indicator.style.height = `${tabRect.height}px`;
indicator.style.left = '0';
indicator.style.width = '';
} else {
indicator.style.left = `${tabRect.left - listRect.left + tablist.scrollLeft}px`;
indicator.style.width = `${tabRect.width}px`;
indicator.style.top = '';
indicator.style.height = '';
}
};
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 === 'bit-tab-item');
if (!tab || tab.hasAttribute('disabled')) return;
// Guard: only respond to tab-items that belong to THIS tabs instance
if (tab.closest('bit-tabs') !== host.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 = createPressControl({
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 === 'bit-tab-item',
);
const focused = activeTabFromEvent ? tabs.indexOf(activeTabFromEvent) : -1;
if (focused >= 0) focusedIndex.value = focused;
if (listControl.handleKeydown(e)) return;
manualActivationPress.handleKeydown(e);
};
host.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 (bit-tab-item)
import { define, computed, effect, html, inject } from '@vielzeug/craftit';
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 BitTabItemProps = {
/** Whether this tab is currently selected (set by bit-tabs) */
active?: boolean;
/** Theme color (inherited from bit-tabs) */
color?: ThemeColor;
/** Disable this tab */
disabled?: boolean;
/** Size (inherited from bit-tabs) */
size?: ComponentSize;
/** Unique value identifier — must match a bit-tab-panel value */
value: string;
/** Visual variant (inherited from bit-tabs) */
variant?: VisualVariant;
};
/**
* Individual tab trigger. Must be placed in the `tabs` slot of `bit-tabs`.
*
* @element bit-tab-item
*
* @attr {string} value - Unique identifier, matches the corresponding bit-tab-panel value
* @attr {boolean} active - Set by the parent bit-tabs when this tab is selected
* @attr {boolean} disabled - Prevents selection
* @attr {string} size - 'sm' | 'md' | 'lg'
* @attr {string} variant - Inherited from bit-tabs
* @attr {string} color - Inherited from bit-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 --border-2 - Border token.
* @cssprop --color-canvas - Base surface background color.
* @cssprop --color-contrast - Contrast color token for text and surfaces.
* @cssprop --color-contrast-0 - Contrast color token for text and surfaces.
* @cssprop --color-contrast-100 - Contrast color token for text and surfaces.
* @cssprop --color-contrast-200 - Contrast color token for text and surfaces.
* @cssprop --color-secondary-contrast - Secondary accent color token.
* @cssprop --font-medium - Font-weight token.
* @cssprop --inset-shadow-xs - Component styling token.
* @cssprop --rounded-lg - Border radius token.
* @cssprop --shadow-xs - Shadow/elevation token.
* @cssprop --size-1 - Spacing/sizing token.
* @part tab - Tab trigger element.
* @example
* ```html
* <bit-tab-item slot="tabs" value="overview">Overview</bit-tab-item>
* <bit-tab-item slot="tabs" value="settings" disabled>Settings</bit-tab-item>
* ```
*/
export const TAB_ITEM_TAG = define<BitTabItemProps>('bit-tab-item', {
props: {
active: { default: false, reflect: false },
color: undefined,
disabled: false,
size: undefined,
value: '',
variant: undefined,
},
setup(props, { host }) {
const tabsCtx = inject(TABS_CTX);
if (tabsCtx) {
effect(() => {
const color = tabsCtx.color.value;
const size = tabsCtx.size.value;
const variant = tabsCtx.variant.value;
if (color !== undefined) props.color.value = color;
if (size !== undefined) props.size.value = size;
if (variant !== undefined) props.variant.value = variant;
});
}
const isActive = computed(() =>
tabsCtx ? !!tabsCtx.value.value && tabsCtx.value.value === props.value.value : props.active.value,
);
const isDisabled = () => Boolean(props.disabled.value);
host.bind({
attr: {
active: () => (isActive.value ? true : undefined),
},
});
const handleClick = (event: MouseEvent) => {
event.stopPropagation();
if (isDisabled()) {
event.preventDefault();
return;
}
host.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 (bit-tab-panel)
import { define, prop, computed, effect, html, inject, signal, styleMap, when } from '@vielzeug/craftit';
import { reducedMotionMixin } from '../../styles';
import { TABS_CTX } from '../tabs/tabs';
import styles from './tab-panel.css?inline';
const TAB_PANEL_PADDING_MAP: 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 BitTabPanelProps = {
/** Active state (managed by bit-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 bit-tab-item */
value: string;
};
/**
* Content panel for a tab. Shown when its `value` matches the selected tab.
*
* @element bit-tab-panel
*
* @attr {string} value - Must match the corresponding bit-tab-item value
* @attr {boolean} active - Toggled by the parent bit-tabs
* @attr {string} padding - Panel padding: 'none' | 'xs' | 'sm' | 'md' (default) | 'lg' | 'xl' | '2xl'
*
* @slot - Panel content
*
* @cssprop --ease-out - Animation easing token.
* @cssprop --size-4 - Spacing/sizing token.
* @cssprop --tab-panel-font-size - Tabs layout/styling token.
* @cssprop --tab-panel-padding - Tabs layout/styling token.
* @cssprop --text-color-body - Font-size token.
* @cssprop --text-sm - Font-size token.
* @cssprop --transition-normal - Transition timing token.
* @part panel - Panel container.
* @example
* ```html
* <bit-tab-panel value="overview"><p>Overview content here</p></bit-tab-panel>
* <bit-tab-panel value="settings" padding="lg"><p>Large padding</p></bit-tab-panel>
* <bit-tab-panel value="code" padding="none"><pre>No padding for code</pre></bit-tab-panel>
* ```
*/
export const TAB_PANEL_TAG = define<BitTabPanelProps>('bit-tab-panel', {
props: {
active: { default: false, reflect: false },
lazy: false,
padding: prop.oneOf(['none', 'xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const, 'md'),
value: '',
},
setup(props, { host }) {
const tabsCtx = inject(TABS_CTX);
const isActive = computed(() =>
tabsCtx ? !!tabsCtx.value.value && tabsCtx.value.value === props.value.value : props.active.value,
);
host.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_MAP[key] ?? TAB_PANEL_PADDING_MAP.md;
});
// Track whether the panel has ever been active (for lazy rendering)
const hasBeenActive = signal(false);
effect(() => {
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
<bit-tabs value="overview">
<bit-tab-item slot="tabs" value="overview">Overview</bit-tab-item>
<bit-tab-item slot="tabs" value="settings">Settings</bit-tab-item>
<bit-tab-item slot="tabs" value="billing">Billing</bit-tab-item>
<bit-tab-panel value="overview"><p>Overview content.</p></bit-tab-panel>
<bit-tab-panel value="settings"><p>Settings content.</p></bit-tab-panel>
<bit-tab-panel value="billing"><p>Billing content.</p></bit-tab-panel>
</bit-tabs>
<script type="module">
import '@vielzeug/buildit/tabs';
import '@vielzeug/buildit/tab-item';
import '@vielzeug/buildit/tab-panel';
</script>Visual Options
Variants
Solid (Default)
Pill-style tabs in a rounded container — clean and contained.
Flat
Tabs and panel share a single container background — they read as one unified block.
Bordered
Tabs that visually connect to their panel with a shared border.
Ghost
Open tabs with a filled active pill — no container border, floats freely.
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.
Colors
Set color on bit-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.
Sizes
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.
Vertical + Manual Activation
For keyboard-heavy interfaces, pair vertical tabs with activation="manual" so arrow keys move focus and Enter/Space commits selection.
Customization
Icons & Badges
Use the prefix and suffix slots on bit-tab-item to add icons or notification badges.
States
Lazy Panels
Add lazy to a bit-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.
<bit-tabs value="tab1">
<bit-tab-item slot="tabs" value="tab1">Quick</bit-tab-item>
<bit-tab-item slot="tabs" value="tab2">Heavy</bit-tab-item>
<bit-tab-panel value="tab1"><p>Rendered immediately.</p></bit-tab-panel>
<bit-tab-panel value="tab2" lazy>
<!-- Only rendered after the "Heavy" tab is first clicked -->
<my-heavy-component></my-heavy-component>
</bit-tab-panel>
</bit-tabs>Disabled Tabs
Prevent specific tabs from being selected.
Keyboard Navigation
| Key | Action |
|---|---|
ArrowRight | Move to the next tab (wraps around) |
ArrowLeft | Move to the previous tab (wraps around) |
Home | Jump to the first tab |
End | Jump to the last tab |
Disabled tabs are skipped during keyboard navigation.
API Reference
bit-tabs Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | — | Value 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 |
bit-tabs Events
| Event | Detail | Description |
|---|---|---|
change | { value: string } | Fired when the active tab changes |
bit-tabs Slots
| Slot | Description |
|---|---|
tabs | Place bit-tab-item elements here |
| (default) | Place bit-tab-panel elements here |
bit-tab-item Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. Must match the corresponding bit-tab-panel value |
active | boolean | false | Whether this tab is selected (managed by bit-tabs) |
disabled | boolean | false | Prevents the tab from being selected |
size | 'sm' | 'md' | 'lg' | inherited | Inherited from parent bit-tabs |
variant | string | inherited | Inherited from parent bit-tabs |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | inherited | Inherited from parent bit-tabs |
bit-tab-item Slots
| Slot | Description |
|---|---|
prefix | Icon or content before the label |
| (default) | Tab label text |
suffix | Badge or count after the label |
bit-tab-panel Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. Must match the corresponding bit-tab-item value |
active | boolean | false | Whether this panel is visible (managed by bit-tabs) |
lazy | boolean | false | Defer rendering slot content until the panel is first activated |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--tabs-transition | var(--transition-normal) | Transition speed for tab hover states |
--tabs-radius | var(--rounded-lg) | Border radius of the tab bar container |
--tab-panel-padding | var(--size-4) | Padding inside each tab panel |
Accessibility
The tabs component follows the WAI-ARIA Tabs Pattern best practices.
bit-tabs
✅ Keyboard Navigation
ArrowRight/ArrowLeftnavigate between tabs;Home/Endjump to first / last.- Disabled tabs are skipped during keyboard navigation.
✅ Screen Readers
- The tab list has
role="tablist". - Each tab has
role="tab"witharia-selectedandaria-controlspointing to its panel. - Each panel has
role="tabpanel"witharia-labelledbypointing 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
valueonbit-tabsso a tab is active on first render. - Use the
prefixandsuffixslots onbit-tab-itemto add icons or notification counts. - Use
variant="bordered"orvariant="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.