Skip to content

Tabs

TabsThe arrow keys move between the tabs; Tab moves on to the panel.
Thymeleaf source
<sl:tabs id="tabs-demo" active="account">
<sl:tabs-list>
<sl:tabs-trigger tab="account">Account</sl:tabs-trigger>
<sl:tabs-trigger tab="password">Password</sl:tabs-trigger>
</sl:tabs-list>
<sl:tabs-content tab="account">
<sl:card>
<sl:card-header>
<sl:card-title level="2">Account</sl:card-title>
<sl:card-description>Make changes to your account here. Click save when you're done.</sl:card-description>
</sl:card-header>
<sl:card-content>
<sl:field-group>
<sl:field for="tabs-demo-name">
<sl:field-label>Name</sl:field-label>
<sl:input value="Pedro Duarte"/>
</sl:field>
<sl:field for="tabs-demo-username">
<sl:field-label>Username</sl:field-label>
<sl:input value="@peduarte"/>
</sl:field>
</sl:field-group>
</sl:card-content>
<sl:card-footer><sl:button>Save changes</sl:button></sl:card-footer>
</sl:card>
</sl:tabs-content>
<sl:tabs-content tab="password">
<sl:card>
<sl:card-header>
<sl:card-title level="2">Password</sl:card-title>
<sl:card-description>Change your password here. After saving, you'll be logged out.</sl:card-description>
</sl:card-header>
<sl:card-content>
<sl:field-group>
<sl:field for="tabs-demo-current">
<sl:field-label>Current password</sl:field-label>
<sl:input type="password"/>
</sl:field>
<sl:field for="tabs-demo-new">
<sl:field-label>New password</sl:field-label>
<sl:input type="password"/>
</sl:field>
</sl:field-group>
</sl:card-content>
<sl:card-footer><sl:button>Save password</sl:button></sl:card-footer>
</sl:card>
</sl:tabs-content>
</sl:tabs>
Rendered HTML
<div class="tabs" x-data="slTabs" id="tabs-demo">
<div class="tabs-list" role="tablist">
<button type="button" class="tabs-trigger" role="tab" id="tabs-demo-account-trigger" aria-controls="tabs-demo-account-content" aria-selected="true">Account</button>
<button type="button" class="tabs-trigger" role="tab" id="tabs-demo-password-trigger" aria-controls="tabs-demo-password-content" aria-selected="false">Password</button>
</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-demo-account-content" aria-labelledby="tabs-demo-account-trigger">
<div class="card">
<div class="card-header">
<div class="card-title" role="heading" aria-level="2">Account</div>
<div class="card-description">Make changes to your account here. Click save when you&apos;re done.</div>
</div>
<div class="card-content">
<div class="field-group">
<div class="field">
<label class="label field-label" for="tabs-demo-name">Name</label>
<input class="input" type="text" value="Pedro Duarte" id="tabs-demo-name">
</div>
<div class="field">
<label class="label field-label" for="tabs-demo-username">Username</label>
<input class="input" type="text" value="@peduarte" id="tabs-demo-username">
</div>
</div>
</div>
<div class="card-footer">
<button class="button" type="button">Save changes</button>
</div>
</div>
</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-demo-password-content" aria-labelledby="tabs-demo-password-trigger" hidden>
<div class="card">
<div class="card-header">
<div class="card-title" role="heading" aria-level="2">Password</div>
<div class="card-description">Change your password here. After saving, you&apos;ll be logged out.</div>
</div>
<div class="card-content">
<div class="field-group">
<div class="field">
<label class="label field-label" for="tabs-demo-current">Current password</label>
<input class="input" type="password" id="tabs-demo-current">
</div>
<div class="field">
<label class="label field-label" for="tabs-demo-new">New password</label>
<input class="input" type="password" id="tabs-demo-new">
</div>
</div>
</div>
<div class="card-footer">
<button class="button" type="button">Save password</button>
</div>
</div>
</div>
</div>

Tabs are <sl:tabs> with an <sl:tabs-list> of <sl:tabs-trigger>s and an <sl:tabs-content> per tab, as in shadcn/ui. A trigger and its panel share a tab, and active on <sl:tabs> names the one that shows:

<sl:tabs id="settings" active="account">
<sl:tabs-list aria-label="Settings">
<sl:tabs-trigger tab="account">Account</sl:tabs-trigger>
<sl:tabs-trigger tab="password">Password</sl:tabs-trigger>
</sl:tabs-list>
<sl:tabs-content tab="account">…</sl:tabs-content>
<sl:tabs-content tab="password">…</sl:tabs-content>
</sl:tabs>

shadcn/ui calls these value and defaultValue; value is taken in Thymeleaf (th:value is Spring’s form value). The tabs need an id: the triggers and panels get ids derived from it and their tab (settings-account-trigger, settings-account-content), which link each tab to its panel (aria-controls, aria-labelledby). So a tab is letters, digits and dashes.

The server renders the active tab’s panel and hides the others (hidden), so the page is right before any script runs. The Alpine component slTabs then switches panels in the browser and adds the keyboard of the ARIA tabs pattern. An aria-label on the list names it, which helps when a page has more than one.

A trigger with an href is a link. Without JavaScript (or before Alpine starts) it loads the page with that tab active, so every panel is reachable; with Alpine the tab switches in place, and the page’s address becomes the link’s (history.replaceState), so a reload or a shared link opens the same tab. A click with Ctrl, Cmd or Shift still opens the link in a new tab or window. The controller reads the tab from the request:

<sl:tabs id="settings" th:active="${tab}">
<sl:tabs-list aria-label="Settings">
<sl:tabs-trigger tab="account" th:href="@{/settings(tab=account)}">Account</sl:tabs-trigger>
<sl:tabs-trigger tab="password" th:href="@{/settings(tab=password)}">Password</sl:tabs-trigger>
</sl:tabs-list>
…
</sl:tabs>
@GetMapping("/settings")
public String settings(@RequestParam(defaultValue = "account") String tab, Model model) {
model.addAttribute("tab", TABS.contains(tab) ? tab : "account");
return "settings";
}

Triggers without an href are buttons, which need Alpine: without it only the active panel can be seen. Use links for tabs that must work without JavaScript.

LinksTriggers with an href load the page with that tab active when there is no JavaScript; with it, the tab switches in place and the address follows.
Thymeleaf source
<sl:tabs id="tabs-links" active="week" activation-mode="manual">
<sl:tabs-list aria-label="Period">
<sl:tabs-trigger tab="day" href="?period=day">Day</sl:tabs-trigger>
<sl:tabs-trigger tab="week" href="?period=week">Week</sl:tabs-trigger>
<sl:tabs-trigger tab="month" href="?period=month">Month</sl:tabs-trigger>
</sl:tabs-list>
<sl:tabs-content tab="day">14 orders today.</sl:tabs-content>
<sl:tabs-content tab="week">86 orders this week.</sl:tabs-content>
<sl:tabs-content tab="month">312 orders this month.</sl:tabs-content>
</sl:tabs>
Rendered HTML
<div class="tabs" x-data="slTabs" id="tabs-links" data-activation="manual">
<div class="tabs-list" role="tablist" aria-label="Period">
<a class="tabs-trigger" role="tab" id="tabs-links-day-trigger" aria-controls="tabs-links-day-content" aria-selected="false" href="?period=day">Day</a>
<a class="tabs-trigger" role="tab" id="tabs-links-week-trigger" aria-controls="tabs-links-week-content" aria-selected="true" href="?period=week">Week</a>
<a class="tabs-trigger" role="tab" id="tabs-links-month-trigger" aria-controls="tabs-links-month-content" aria-selected="false" href="?period=month">Month</a>
</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-links-day-content" aria-labelledby="tabs-links-day-trigger" hidden>14 orders today.</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-links-week-content" aria-labelledby="tabs-links-week-trigger">86 orders this week.</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-links-month-content" aria-labelledby="tabs-links-month-trigger" hidden>312 orders this month.</div>
</div>

Tab moves into the list on the active tab, and out of it to the active panel (which takes the focus, as in Radix, so a panel without controls can be scrolled and read). In the list:

Key Does
Left and Right arrow The previous and next tab, wrapping around; they swap on a right-to-left page. Up and Down with orientation="vertical".
Home, End The first and last tab.
Enter, Space Shows the focused tab’s panel (for activation-mode="manual").

Disabled tabs are skipped. With activation-mode="automatic" (the default) a tab shows its panel as soon as the arrow keys reach it. manual only moves the focus, and Enter or Space shows the panel: for panels that take a request to show, such as ones loaded with htmx, so that arrowing past a tab does not load it.

variant="line" on the list draws no track, and a line under the active tab:

Line
Thymeleaf source
<sl:tabs id="tabs-line" active="overview">
<sl:tabs-list variant="line" aria-label="Report">
<sl:tabs-trigger tab="overview">Overview</sl:tabs-trigger>
<sl:tabs-trigger tab="analytics">Analytics</sl:tabs-trigger>
<sl:tabs-trigger tab="reports">Reports</sl:tabs-trigger>
</sl:tabs-list>
<sl:tabs-content tab="overview">Sales are up 12% on last month.</sl:tabs-content>
<sl:tabs-content tab="analytics">1,204 visitors this week.</sl:tabs-content>
<sl:tabs-content tab="reports">Three reports are ready to download.</sl:tabs-content>
</sl:tabs>
Rendered HTML
<div class="tabs" x-data="slTabs" id="tabs-line">
<div class="tabs-list" role="tablist" data-variant="line" aria-label="Report">
<button type="button" class="tabs-trigger" role="tab" id="tabs-line-overview-trigger" aria-controls="tabs-line-overview-content" aria-selected="true">Overview</button>
<button type="button" class="tabs-trigger" role="tab" id="tabs-line-analytics-trigger" aria-controls="tabs-line-analytics-content" aria-selected="false">Analytics</button>
<button type="button" class="tabs-trigger" role="tab" id="tabs-line-reports-trigger" aria-controls="tabs-line-reports-content" aria-selected="false">Reports</button>
</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-line-overview-content" aria-labelledby="tabs-line-overview-trigger">Sales are up 12% on last month.</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-line-analytics-content" aria-labelledby="tabs-line-analytics-trigger" hidden>1,204 visitors this week.</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-line-reports-content" aria-labelledby="tabs-line-reports-trigger" hidden>Three reports are ready to download.</div>
</div>

orientation="vertical" puts the tabs in a column next to the panels:

Verticalorientation="vertical" puts the tabs in a column; the up and down arrow keys move between them.
Thymeleaf source
<sl:tabs id="tabs-vertical" active="general" orientation="vertical">
<sl:tabs-list aria-label="Preferences">
<sl:tabs-trigger tab="general">General</sl:tabs-trigger>
<sl:tabs-trigger tab="appearance">Appearance</sl:tabs-trigger>
<sl:tabs-trigger tab="advanced">Advanced</sl:tabs-trigger>
</sl:tabs-list>
<sl:tabs-content tab="general">Language, time zone and start page.</sl:tabs-content>
<sl:tabs-content tab="appearance">Theme and density.</sl:tabs-content>
<sl:tabs-content tab="advanced">Developer options.</sl:tabs-content>
</sl:tabs>
Rendered HTML
<div class="tabs" x-data="slTabs" id="tabs-vertical" data-orientation="vertical">
<div class="tabs-list" role="tablist" aria-orientation="vertical" aria-label="Preferences">
<button type="button" class="tabs-trigger" role="tab" id="tabs-vertical-general-trigger" aria-controls="tabs-vertical-general-content" aria-selected="true">General</button>
<button type="button" class="tabs-trigger" role="tab" id="tabs-vertical-appearance-trigger" aria-controls="tabs-vertical-appearance-content" aria-selected="false">Appearance</button>
<button type="button" class="tabs-trigger" role="tab" id="tabs-vertical-advanced-trigger" aria-controls="tabs-vertical-advanced-content" aria-selected="false">Advanced</button>
</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-vertical-general-content" aria-labelledby="tabs-vertical-general-trigger">Language, time zone and start page.</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-vertical-appearance-content" aria-labelledby="tabs-vertical-appearance-trigger" hidden>Theme and density.</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-vertical-advanced-content" aria-labelledby="tabs-vertical-advanced-trigger" hidden>Developer options.</div>
</div>
Icons and a disabled tabThe arrow keys skip a disabled tab.
Thymeleaf source
<sl:tabs id="tabs-icons" active="preview">
<sl:tabs-list aria-label="View">
<sl:tabs-trigger tab="preview"><sl:icon name="app-window"/> Preview</sl:tabs-trigger>
<sl:tabs-trigger tab="code"><sl:icon name="code"/> Code</sl:tabs-trigger>
<sl:tabs-trigger tab="history" disabled><sl:icon name="history"/> History</sl:tabs-trigger>
</sl:tabs-list>
<sl:tabs-content tab="preview">The rendered page.</sl:tabs-content>
<sl:tabs-content tab="code">The template.</sl:tabs-content>
<sl:tabs-content tab="history">Earlier versions.</sl:tabs-content>
</sl:tabs>
Rendered HTML
<div class="tabs" x-data="slTabs" id="tabs-icons">
<div class="tabs-list" role="tablist" aria-label="View">
<button type="button" class="tabs-trigger" role="tab" id="tabs-icons-preview-trigger" aria-controls="tabs-icons-preview-content" aria-selected="true">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
<path d="M10 4v4"></path>
<path d="M2 8h20"></path>
<path d="M6 4v4"></path>
</svg>
Preview
</button>
<button type="button" class="tabs-trigger" role="tab" id="tabs-icons-code-trigger" aria-controls="tabs-icons-code-content" aria-selected="false">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m16 18 6-6-6-6"></path>
<path d="m8 6-6 6 6 6"></path>
</svg>
Code
</button>
<button type="button" class="tabs-trigger" role="tab" id="tabs-icons-history-trigger" aria-controls="tabs-icons-history-content" aria-selected="false" disabled>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
<path d="M3 3v5h5"></path>
<path d="M12 7v5l4 2"></path>
</svg>
History
</button>
</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-icons-preview-content" aria-labelledby="tabs-icons-preview-trigger">The rendered page.</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-icons-code-content" aria-labelledby="tabs-icons-code-trigger" hidden>The template.</div>
<div class="tabs-content" role="tabpanel" tabindex="0" id="tabs-icons-history-content" aria-labelledby="tabs-icons-history-trigger" hidden>Earlier versions.</div>
</div>

A disabled button tab gets disabled; a disabled link tab loses its href and gets aria-disabled.

Once Alpine runs, the inactive panels are hidden="until-found" instead of hidden: the browser’s find-in-page, and a link to an element inside a panel (#billing-address), find their text, and the tab of the panel with the match becomes active (the beforematch event). This works in Chrome and Edge 102, Firefox 148 and Safari 26.2 (Safari and Firefox 139 to 147 do not scroll to the match yet). The server renders plain hidden: without Alpine a match would show the panel under a tab that stays inactive.

A panel that loads on first view. Each time a panel is shown, slTabs fires sl-tabs-show on it, so htmx can fetch its content the first time:

<sl:tabs id="settings" th:active="${tab}" activation-mode="manual">
<sl:tabs-list aria-label="Settings">
<sl:tabs-trigger tab="account" th:href="@{/settings(tab=account)}">Account</sl:tabs-trigger>
<sl:tabs-trigger tab="sessions" th:href="@{/settings(tab=sessions)}">Sessions</sl:tabs-trigger>
</sl:tabs-list>
<sl:tabs-content tab="account" th:insert="~{settings :: account}"/>
<sl:tabs-content tab="sessions" th:hx-get="${tab != 'sessions'} ? @{/settings/sessions}" hx-trigger="sl-tabs-show once">
<th:block th:if="${tab == 'sessions'}" th:insert="~{settings :: sessions}"/>
</sl:tabs-content>
</sl:tabs>

The server renders the active panel’s content itself: sl-tabs-show is only fired when a panel is shown in the browser, not for the one that is active when the page loads. The event does not bubble, so a panel’s hx-trigger never reacts to the tabs nested in it. activation-mode="manual" keeps the arrow keys from loading every panel on the way.

A swap that replaces the tabs shows the tab the new markup has active: send the active the user had (for example with hx-vals or hx-include) if it should stay.

History. htmx saves the page’s markup as it is when it navigates away, with the tab the user chose and any panel content loaded since; back restores exactly that. With link triggers the address has the tab too.

  • value/onValueChange and controlled state: the server renders the active tab; the sl-tabs-show event reports a switch.
  • dir: the arrow keys follow the page’s direction (dir on an ancestor or the page).
  • loop: the arrow keys always wrap around, as Radix’s do by default.
  • forceMount: every panel is always in the page; the inactive ones are hidden.
AttributeValuesDefaultDescription
idstringrequiredThe id of the tabs. The ids of the triggers and panels derive from it (settings-account-trigger, settings-account-content).
activestringrequiredThe tab of the trigger and panel that are active when the page renders, e.g. from a request parameter: th:active="${tab}".
orientationhorizontal verticalhorizontalvertical puts the tabs in a column next to the panels, and the up and down arrow keys move between them.
activation-modeautomatic manualautomaticautomatic: a tab shows its panel as soon as the arrow keys reach it. manual: the arrow keys only move the focus, and Enter or Space shows the panel; for panels that are slow to show, such as ones loaded with htmx.

Every prop can also be set from an expression: th:id="${...}". Any other attribute passes through to the rendered element, and class is appended.

AttributeValuesDefaultDescription
variantdefault linedefaultdefault: the tabs sit on a muted track and the active one is raised. line: no track, a line under the active tab.

Every prop can also be set from an expression: th:variant="${...}". Any other attribute passes through to the rendered element, and class is appended.

AttributeValuesDefaultDescription
tabstringrequiredWhich tab this is: the tab of its sl:tabs-content, and the value sl:tabs' active names. Part of the ids, so letters, digits and dashes.
disabledboolean—The tab cannot be chosen; the arrow keys skip it. A disabled link loses its href.

Every prop can also be set from an expression: th:tab="${...}". Any other attribute passes through to the rendered element, and class is appended.

AttributeValuesDefaultDescription
tabstringrequiredWhich tab's panel this is: the tab of its sl:tabs-trigger.

Every prop can also be set from an expression: th:tab="${...}". Any other attribute passes through to the rendered element, and class is appended.

A small Alpine component, slTabs, is on <sl:tabs>: it renders as <div class="tabs" x-data="slTabs">.

  • Without Alpine the active panel shows, and link triggers load the page with another tab active. Button triggers need Alpine. Alpine is included by default; see Alpine.
  • It needs no Alpine plugin. If you load Alpine yourself (shadleaf.assets.alpine=external), plain Alpine is enough.
  • Keep your own x-data off <sl:tabs>. An element has one x-data; put yours on an element inside.
Event On Effect
sl-tabs-show a panel (it does not bubble) Fired when the panel is shown in the browser: a click, the keyboard, or find-in-page.