Tabs pattern with roving tabindex, arrow-key navigation, and optional URL hash sync.
General Settings
Manage your account name, email, and preferences.
Security Settings
Update your password and two-factor authentication options.
Notification Settings
Choose which notifications you want to receive.
Decoupled panels — tablist and panels in separate scopes:
General Settings
Manage your account name, email, and preferences.
Security Settings
Update your password and two-factor authentication options.
Notification Settings
Choose which notifications you want to receive.
Alpine.js Tabs
huxTabs manages active tab state, keyboard focus movement, and optional URL hash synchronization. Use it to attach behavior to your own tablist and panel markup. Panels can either live inside the same x-data scope (owned) or in a separate scope via huxTabPanel (decoupled).
API
huxTabs(config)
Returns an Alpine data object with:
activeTabId: string | nulltabItems: Array<{ label: string, id: string }>useHash: booleantabsId: string | nullactiveTabIndex: numberselectTab(tabId: string): voidfocusTab(tabId: string): voidfocusNextTab(): voidfocusPreviousTab(): voidfocusFirstTab(): voidfocusLastTab(): void
Internal helper methods are private implementation details and are not part of the supported API contract.
huxTabPanel(config)
Returns an Alpine data object for a decoupled tab panel with:
isActive: booleantabsId: stringtabId: string
Options
huxTabs
tabItems: Array<{ label: string, id: string }>(default:[])activeTab: string(optional initial active id)useHash: boolean(default:false)id: string(optional; enableshux-tabs:{id}:changeevents for decoupled panels)
huxTabPanel
tabsId: string(required; matches theidpassed tohuxTabs)tabId: string(required; the tab id this panel belongs to)
Quick Start
<div
x-data="huxTabs({
tabItems: [
{ label: 'General', id: 'general' },
{ label: 'Security', id: 'security' }
],
useHash: true
})"
>
<div role="tablist" aria-label="Settings">
<template x-for="tabItem in tabItems" x-bind:key="tabItem.id">
<button
type="button"
role="tab"
x-bind:id="`tab-${tabItem.id}`"
x-bind:aria-controls="`panel-${tabItem.id}`"
x-bind:aria-selected="(activeTabId === tabItem.id).toString()"
x-bind:tabindex="activeTabId === tabItem.id ? '0' : '-1'"
x-on:click="selectTab(tabItem.id)"
x-on:keydown.right.prevent="focusNextTab()"
x-on:keydown.left.prevent="focusPreviousTab()"
x-text="tabItem.label"
></button>
</template>
</div>
<section
id="panel-general"
role="tabpanel"
aria-labelledby="tab-general"
x-show="activeTabId === 'general'"
x-cloak
>
...
</section>
</div>Common Usage Patterns
Initialize Active Tab From Hash
huxTabs({
tabItems: [
{ label: 'General', id: 'general' },
{ label: 'Security', id: 'security' },
{ label: 'Notifications', id: 'notifications' },
],
useHash: true,
})Provide Explicit Initial Tab
huxTabs({
tabItems: [
{ label: 'General', id: 'general' },
{ label: 'Security', id: 'security' },
],
activeTab: 'security',
})Decoupled Panels
Use id on huxTabs and huxTabPanel on each panel to place tablist and panels in separate DOM scopes.
<div
x-data="huxTabs({
id: 'settings',
tabItems: [
{ label: 'General', id: 'general' },
{ label: 'Security', id: 'security' }
]
})"
>
<div role="tablist" aria-label="Settings">
<template x-for="tabItem in tabItems" x-bind:key="tabItem.id">
<button
type="button"
role="tab"
x-bind:id="`tab-${tabItem.id}`"
x-bind:aria-controls="`panel-${tabItem.id}`"
x-bind:aria-selected="(activeTabId === tabItem.id).toString()"
x-bind:tabindex="activeTabId === tabItem.id ? '0' : '-1'"
x-on:click="selectTab(tabItem.id)"
x-on:keydown.right.prevent="focusNextTab()"
x-on:keydown.left.prevent="focusPreviousTab()"
x-text="tabItem.label"
></button>
</template>
</div>
</div>
<section
id="panel-general"
role="tabpanel"
aria-labelledby="tab-general"
tabindex="0"
x-data="huxTabPanel({ tabsId: 'settings', tabId: 'general' })"
x-show="isActive"
x-cloak
>
...
</section>
<section
id="panel-security"
role="tabpanel"
aria-labelledby="tab-security"
tabindex="0"
x-data="huxTabPanel({ tabsId: 'settings', tabId: 'security' })"
x-show="isActive"
x-cloak
>
...
</section>Behavior Contract
- Invalid tab items are filtered out during initialization.
- When
useHashis enabled, the component decodeslocation.hashand uses it when it matches a known tab id. - If initial active id is missing or invalid, active tab falls back to the first valid tab id.
selectTab()ignores unknown ids and only updates state for valid ids.focusNextTab()andfocusPreviousTab()wrap at boundaries.- When
useHashis enabled,selectTab()updates hash withhistory.replaceState. - When
idis set,selectTab()dispatcheshux-tabs:{id}:changeonwindowwith{ tabId }inevent.detail. No event is dispatched whenactiveTabIdisnull. - When
idis set, the initial active tab is also dispatched viahux-tabs:{id}:changeafter all components in the current render have initialized. No event is dispatched whentabItemsis empty. huxTabPaneltogglesisActiveon everyhux-tabs:{tabsId}:changeevent —truewhen the event’stabIdmatches its owntabId,falseotherwise.
Error Handling
- Malformed hash decoding is caught and ignored without throwing.
- Unknown tab ids no-op in
selectTab()and focus helpers. - Empty tab arrays initialize with
activeTabId = null. huxTabPanellogs a console error and no-ops whentabsIdortabIdis missing.
Accessibility Notes
- Use ARIA tabs pattern roles and relationships:
role="tablist",role="tab",role="tabpanel",aria-controls, andaria-labelledby. - Keep roving tabindex (
0for active tab,-1for inactive tabs) so keyboard focus order is predictable. - Wire arrow keys plus Home/End for expected tab keyboard behavior.
- Keep tab controls as
button type="button"and ensure visible active-state contrast.
Notes
- Hash sync uses
replaceState, so tab changes do not add browser history entries. - Events emitted when
idis set:hux-tabs:{id}:change— dispatched onwindowwith{ tabId: string }detail on init and on everyselectTab()call. Not dispatched whenactiveTabIdisnull(empty tab list).
- Owned panels (no
idneeded) still work viax-show="activeTabId === 'general'"inside the samex-datascope.