Tabs
A set of layered sections of content, known as tab panels, that display one panel of content at a time.
Demo
Automatic Activation (Default)
Tabs are activated automatically when focused with arrow keys.
This is the overview panel content. It provides a general introduction to the product or service.
Keyboard navigation support, ARIA compliant, Automatic and manual activation modes.
Pricing information would be displayed here.
Manual Activation
Tabs require Enter or Space to activate after focusing.
Content for tab one. Press Enter or Space to activate tabs.
Content for tab two.
Content for tab three.
Vertical Orientation
Tabs arranged vertically with Up/Down arrow navigation.
Configure your application settings here.
Manage your profile information.
Set your notification preferences.
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
tablist | Container | Container for tab elements |
tab | Each tab | Individual tab element |
tabpanel | Panel | Content area for each tab |
WAI-ARIA tablist role (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Configuration |
|---|---|---|---|---|
aria-orientation | tablist | "horizontal" | "vertical" | No | orientation prop |
aria-controls | tab | ID reference to associated panel | Yes | Auto-generated |
aria-labelledby | tabpanel | ID reference to associated tab | Yes | Auto-generated |
WAI-ARIA States
aria-selected
Indicates the currently active tab.
| Target | tab element |
| Values | true | false |
| Required | Yes |
| Change Trigger | Tab click, Arrow keys (automatic), Enter/Space (manual) |
| Reference | aria-selected (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus into/out of the tablist |
| Arrow Right / Arrow Left | Navigate between tabs (horizontal) |
| Arrow Down / Arrow Up | Navigate between tabs (vertical) |
| Home | Move focus to first tab |
| End | Move focus to last tab |
| Enter / Space | Activate tab (manual mode only) |
Source Code
Tabs.astro
---
/**
* APG Tabs Pattern - Astro Implementation
*
* A set of layered sections of content that display one panel at a time.
* Uses Web Components for client-side keyboard navigation and state management.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
*/
export interface TabItem {
id: string;
label: string;
content: string;
disabled?: boolean;
}
export interface Props {
/** Array of tab items */
tabs: TabItem[];
/** Initially selected tab ID */
defaultSelectedId?: string;
/** Orientation of the tabs */
orientation?: "horizontal" | "vertical";
/** Activation mode: 'automatic' selects on arrow key, 'manual' requires Enter/Space */
activation?: "automatic" | "manual";
/** Additional CSS class */
class?: string;
}
const {
tabs,
defaultSelectedId,
orientation = "horizontal",
activation = "automatic",
class: className = "",
} = Astro.props;
// Determine initial selected tab
const initialTab = defaultSelectedId
? tabs.find((tab) => tab.id === defaultSelectedId && !tab.disabled)
: tabs.find((tab) => !tab.disabled);
const selectedId = initialTab?.id || tabs[0]?.id;
// Generate unique ID for this instance
const instanceId = `tabs-${Math.random().toString(36).substring(2, 11)}`;
const containerClass = `apg-tabs ${
orientation === "vertical" ? "apg-tabs--vertical" : "apg-tabs--horizontal"
} ${className}`.trim();
const tablistClass = `apg-tablist ${
orientation === "vertical" ? "apg-tablist--vertical" : "apg-tablist--horizontal"
}`;
---
<apg-tabs
class={containerClass}
data-activation={activation}
data-orientation={orientation}
>
<div role="tablist" aria-orientation={orientation} class={tablistClass}>
{
tabs.map((tab) => {
const isSelected = tab.id === selectedId;
const tabClass = `apg-tab ${
orientation === "vertical" ? "apg-tab--vertical" : "apg-tab--horizontal"
} ${isSelected ? "apg-tab--selected" : ""} ${
tab.disabled ? "apg-tab--disabled" : ""
}`.trim();
return (
<button
role="tab"
type="button"
id={`${instanceId}-tab-${tab.id}`}
aria-selected={isSelected}
aria-controls={isSelected ? `${instanceId}-panel-${tab.id}` : undefined}
tabindex={tab.disabled ? -1 : isSelected ? 0 : -1}
disabled={tab.disabled}
class={tabClass}
data-tab-id={tab.id}
>
<span class="apg-tab-label">{tab.label}</span>
</button>
);
})
}
</div>
<div class="apg-tabpanels">
{
tabs.map((tab) => {
const isSelected = tab.id === selectedId;
return (
<div
role="tabpanel"
id={`${instanceId}-panel-${tab.id}`}
aria-labelledby={`${instanceId}-tab-${tab.id}`}
hidden={!isSelected}
class={`apg-tabpanel ${
isSelected ? "apg-tabpanel--active" : "apg-tabpanel--inactive"
}`}
tabindex={isSelected ? 0 : -1}
data-panel-id={tab.id}
>
<Fragment set:html={tab.content} />
</div>
);
})
}
</div>
</apg-tabs>
<script>
class ApgTabs extends HTMLElement {
private tablist: HTMLElement | null = null;
private tabs: HTMLButtonElement[] = [];
private panels: HTMLElement[] = [];
private availableTabs: HTMLButtonElement[] = [];
private focusedIndex = 0;
private activation: "automatic" | "manual" = "automatic";
private orientation: "horizontal" | "vertical" = "horizontal";
private rafId: number | null = null;
connectedCallback() {
// Use requestAnimationFrame to ensure DOM is fully constructed
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.tablist = this.querySelector('[role="tablist"]');
if (!this.tablist) {
console.warn("apg-tabs: tablist element not found");
return;
}
this.tabs = Array.from(this.querySelectorAll('[role="tab"]'));
this.panels = Array.from(this.querySelectorAll('[role="tabpanel"]'));
if (this.tabs.length === 0 || this.panels.length === 0) {
console.warn("apg-tabs: tabs or panels not found");
return;
}
this.availableTabs = this.tabs.filter((tab) => !tab.disabled);
this.activation =
(this.dataset.activation as "automatic" | "manual") || "automatic";
this.orientation =
(this.dataset.orientation as "horizontal" | "vertical") || "horizontal";
// Find initial focused index
this.focusedIndex = this.availableTabs.findIndex(
(tab) => tab.getAttribute("aria-selected") === "true"
);
if (this.focusedIndex === -1) this.focusedIndex = 0;
// Attach event listeners
this.tablist.addEventListener("click", this.handleClick);
this.tablist.addEventListener("keydown", this.handleKeyDown);
}
disconnectedCallback() {
// Cancel pending initialization
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// Remove event listeners
this.tablist?.removeEventListener("click", this.handleClick);
this.tablist?.removeEventListener("keydown", this.handleKeyDown);
// Clean up references
this.tablist = null;
this.tabs = [];
this.panels = [];
this.availableTabs = [];
}
private selectTab(tabId: string) {
this.tabs.forEach((tab) => {
const isSelected = tab.dataset.tabId === tabId;
tab.setAttribute("aria-selected", String(isSelected));
tab.tabIndex = isSelected ? 0 : -1;
tab.classList.toggle("apg-tab--selected", isSelected);
// Update aria-controls
const panelId = tab.id.replace("-tab-", "-panel-");
if (isSelected) {
tab.setAttribute("aria-controls", panelId);
} else {
tab.removeAttribute("aria-controls");
}
});
this.panels.forEach((panel) => {
const isSelected = panel.dataset.panelId === tabId;
panel.hidden = !isSelected;
panel.tabIndex = isSelected ? 0 : -1;
panel.classList.toggle("apg-tabpanel--active", isSelected);
panel.classList.toggle("apg-tabpanel--inactive", !isSelected);
});
// Dispatch custom event
this.dispatchEvent(
new CustomEvent("tabchange", {
detail: { selectedId: tabId },
bubbles: true,
})
);
}
private focusTab(index: number) {
this.focusedIndex = index;
this.availableTabs[index]?.focus();
}
private handleClick = (e: Event) => {
const target = (e.target as HTMLElement).closest<HTMLButtonElement>(
'[role="tab"]'
);
if (target && !target.disabled) {
this.selectTab(target.dataset.tabId!);
// Update focused index
this.focusedIndex = this.availableTabs.indexOf(target);
}
};
private handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.getAttribute("role") !== "tab") return;
let newIndex = this.focusedIndex;
let shouldPreventDefault = false;
// Determine navigation keys based on orientation
const nextKey =
this.orientation === "vertical" ? "ArrowDown" : "ArrowRight";
const prevKey =
this.orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
switch (e.key) {
case nextKey:
case "ArrowRight":
case "ArrowDown":
newIndex = (this.focusedIndex + 1) % this.availableTabs.length;
shouldPreventDefault = true;
break;
case prevKey:
case "ArrowLeft":
case "ArrowUp":
newIndex =
(this.focusedIndex - 1 + this.availableTabs.length) %
this.availableTabs.length;
shouldPreventDefault = true;
break;
case "Home":
newIndex = 0;
shouldPreventDefault = true;
break;
case "End":
newIndex = this.availableTabs.length - 1;
shouldPreventDefault = true;
break;
case "Enter":
case " ":
if (this.activation === "manual") {
const focusedTab = this.availableTabs[this.focusedIndex];
if (focusedTab) {
this.selectTab(focusedTab.dataset.tabId!);
}
}
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
e.preventDefault();
if (newIndex !== this.focusedIndex) {
this.focusTab(newIndex);
if (this.activation === "automatic") {
const newTab = this.availableTabs[newIndex];
if (newTab) {
this.selectTab(newTab.dataset.tabId!);
}
}
}
}
};
}
// Register the custom element
if (!customElements.get("apg-tabs")) {
customElements.define("apg-tabs", ApgTabs);
}
</script> Usage
Example
---
import Tabs from './Tabs.astro';
const tabs = [
{ id: 'tab1', label: 'First', content: 'First panel content' },
{ id: 'tab2', label: 'Second', content: 'Second panel content' },
{ id: 'tab3', label: 'Third', content: 'Third panel content' }
];
---
<Tabs
tabs={tabs}
defaultSelectedId="tab1"
/>
<script>
// Listen for tab change events
document.querySelector('apg-tabs')?.addEventListener('tabchange', (e) => {
console.log('Tab changed:', e.detail.selectedId);
});
</script> API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
tabs | TabItem[] | required | Array of tab items |
defaultSelectedId | string | first tab | ID of the initially selected tab |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Tab layout direction |
activation | 'automatic' | 'manual' | 'automatic' | How tabs are activated |
class | string | "" | Additional CSS class |
TabItem Interface
Types
interface TabItem {
id: string;
label: string;
content: string;
disabled?: boolean;
} Custom Events
| Event | Detail | Description |
|---|---|---|
tabchange | { selectedId: string } | Fired when the selected tab changes |
This component uses a Web Component (<apg-tabs>) for
client-side keyboard navigation and state management.