Tabs
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'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'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>Built from parts
Section titled “Built from parts”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.
Tabs in the address
Section titled “Tabs in the address”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.
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>Keyboard
Section titled “Keyboard”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:
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>Vertical
Section titled “Vertical”orientation="vertical" puts the tabs in a column next to the panels:
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 disabled tabs
Section titled “Icons and disabled tabs”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.
Find in page
Section titled “Find in page”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.
With htmx
Section titled “With htmx”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.
Left out
Section titled “Left out”value/onValueChangeand controlled state: the server renders the active tab; thesl-tabs-showevent reports a switch.dir: the arrow keys follow the page’s direction (diron 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.
Attributes
Section titled “Attributes”sl:tabs
Section titled “sl:tabs”| Attribute | Values | Default | Description |
|---|---|---|---|
id | string | required | The id of the tabs. The ids of the triggers and panels derive from it (settings-account-trigger, settings-account-content). |
active | string | required | The tab of the trigger and panel that are active when the page renders, e.g. from a request parameter: th:active="${tab}". |
orientation | horizontal vertical | horizontal | vertical puts the tabs in a column next to the panels, and the up and down arrow keys move between them. |
activation-mode | automatic manual | automatic | automatic: 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.
sl:tabs-list
Section titled “sl:tabs-list”| Attribute | Values | Default | Description |
|---|---|---|---|
variant | default line | default | default: 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.
sl:tabs-trigger
Section titled “sl:tabs-trigger”| Attribute | Values | Default | Description |
|---|---|---|---|
tab | string | required | Which 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. |
disabled | boolean | — | 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.
sl:tabs-content
Section titled “sl:tabs-content”| Attribute | Values | Default | Description |
|---|---|---|---|
tab | string | required | Which 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.
JavaScript
Section titled “JavaScript”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-dataoff<sl:tabs>. An element has onex-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. |