Components
Tabs
WAI-ARIA tabs pattern over real <button role="tab"> elements. Arrow keys cycle, Home/End jump to edges, focus stays inside the tablist. An inline boot script sets the active tab before paint, so there's no flicker.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/tabs.json2. Use it
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
<Tabs id="account-tabs" value="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">Account fields…</TabsContent>
<TabsContent value="password">Password fields…</TabsContent>
</Tabs>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Tabs — shadcn-htmx, htmx v4 + Tailwind v4.
//
// We follow the WAI-ARIA APG tabs pattern:
// repos/aria-practices/content/patterns/tabs/tabs-pattern.html
//
// shadcn's React Tabs uses Radix. For our SSR + tiny-JS setup, we render
// real <button role="tab"> elements and <div role="tabpanel"> siblings, then
// public/site.js wires up the keyboard contract (Arrow keys + Home/End +
// Enter/Space) and click handling. The whole component degrades gracefully:
// without JS, the initial active tab is still visible.
//
// Composition (matches shadcn's API):
// <Tabs id="my-tabs" value="account">
// <TabsList>
// <TabsTrigger value="account">Account</TabsTrigger>
// <TabsTrigger value="password">Password</TabsTrigger>
// </TabsList>
// <TabsContent value="account">…</TabsContent>
// <TabsContent value="password">…</TabsContent>
// </Tabs>
export type TabsOrientation = "horizontal" | "vertical"
export type TabsActivation = "automatic" | "manual"
type TabsProps = PropsWithChildren<{
// The id is required so site.js can scope its keyboard handlers per group
// and so panels can be aria-labelledby their trigger.
id: string
// Initial active value. Each TabsTrigger / TabsContent passes a matching
// `value` and the one matching this prop is shown.
value: string
orientation?: TabsOrientation
// APG tabs pattern allows two activation modes:
// - "automatic" (default): arrow keys move focus AND activate the tab.
// Best when revealing a panel is cheap.
// - "manual": arrow keys only move focus; the user must press Space or
// Enter to activate. Best when activating a tab is expensive (network
// request, large render, side effects).
// See repos/aria-practices/content/patterns/tabs/tabs-pattern.html:49,104-107.
activation?: TabsActivation
class?: ClassValue
}>
export function Tabs(props: TabsProps) {
const {
id,
value,
orientation = "horizontal",
activation = "automatic",
class: className,
children,
} = props
// Inline script runs immediately after this element is parsed, so the
// active trigger / panel get their state set before paint — no flicker.
// It also leaves data-tabs-ready on the container so site.js knows the
// group is already bootstrapped and only needs interaction handlers.
//
// aria-controls on each trigger references the panel id, per APG:
// repos/aria-practices/content/patterns/tabs/tabs-pattern.html:132
const boot = `(function(el){
var active = el.getAttribute('data-active-tab');
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
var value = p.getAttribute('data-tab-panel');
p.id = el.id + '-panel-' + value;
});
el.querySelectorAll('[data-tab-trigger]').forEach(function(t){
var value = t.getAttribute('data-tab-trigger');
var on = value === active;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.setAttribute('tabindex', on ? '0' : '-1');
t.setAttribute('aria-controls', el.id + '-panel-' + value);
t.id = el.id + '-trigger-' + value;
});
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
var value = p.getAttribute('data-tab-panel');
var on = value === active;
if (on) p.removeAttribute('hidden'); else p.setAttribute('hidden', '');
p.setAttribute('aria-labelledby', el.id + '-trigger-' + value);
});
var list = el.querySelector('[role="tablist"]');
if (list) list.setAttribute('aria-orientation', el.getAttribute('data-orientation') === 'vertical' ? 'vertical' : 'horizontal');
el.setAttribute('data-tabs-ready','true');
})(document.currentScript.previousElementSibling);`
return (
<>
<div
id={id}
data-slot="tabs"
data-tabs
data-orientation={orientation}
data-activation={activation}
data-active-tab={value}
class={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
>
{children}
</div>
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: SSR boot
dangerouslySetInnerHTML={{ __html: boot }}
/>
</>
)
}
type TabsListProps = PropsWithChildren<{
class?: ClassValue
ariaLabel?: string
// APG prefers aria-labelledby pointing at a visible heading when one exists;
// aria-label is the fallback for an unlabelled tablist. See
// repos/aria-practices/content/patterns/tabs/tabs-pattern.html:129-130.
ariaLabelledby?: string
}>
export function TabsList(props: TabsListProps) {
const { class: className, ariaLabel, ariaLabelledby, children } = props
return (
<div
role="tablist"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
data-slot="tabs-list"
class={cn(
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
"group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
className,
)}
>
{children}
</div>
)
}
type TabsTriggerProps = PropsWithChildren<{
value: string
disabled?: boolean
class?: ClassValue
// htmx and arbitrary attributes ride along onto the underlying tab button,
// mirroring TabsContent. This is the idiomatic host for the htmx lazy-load
// pattern (hx-get + hx-trigger="click once" to fetch the panel only when the
// tab is activated), since panels render `hidden` so hooks there would fire
// on page load. See repos/htmx/www/src/content/patterns/01-loading/03-lazy-load.md.
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function TabsTrigger(props: TabsTriggerProps) {
const { value, disabled, class: className, children, ...rest } = props as any
// aria-selected and tabindex are set at render time based on the parent
// Tabs' active value. site.js maintains them on switch. Both renderings
// (server + client) use the same source of truth.
return (
<button
// rest is spread first so the fixed contract attributes below (type,
// role, data-slot, data-tab-trigger, class) always win and can't be
// clobbered by passthrough.
{...rest}
type="button"
role="tab"
data-slot="tabs-trigger"
data-tab-trigger={value}
disabled={disabled}
class={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all",
"hover:text-foreground",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-50",
"aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm",
"dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground",
"group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
>
{children}
</button>
)
}
type TabsContentProps = PropsWithChildren<{
value: string
class?: ClassValue
// htmx and arbitrary attributes ride along onto the underlying panel div.
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function TabsContent(props: TabsContentProps) {
const { value, class: className, children, ...rest } = props as any
return (
<div
role="tabpanel"
data-slot="tabs-content"
data-tab-panel={value}
tabindex={0}
class={cn(
"flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
className,
)}
{...rest}
>
{children}
</div>
)
}
1. Save the file
Copy tabs.html into templates/components/.
2. Use it
{% from "components/tabs.html" import tabs, tabs_list_open, tabs_list_close, tab_trigger, tab_content %}
{% call tabs(id="account-tabs", value="account") %}
{{ tabs_list_open() }}
{{ tab_trigger("account", "Account") }}
{{ tab_trigger("password", "Password") }}
{{ tabs_list_close() }}
{% call(_) tab_content("account") %}Account fields…{% endcall %}
{% call(_) tab_content("password") %}Password fields…{% endcall %}
{% endcall %}View source
{# Tabs macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/tabs.tsx. Renders the wrapper, list, trigger, and
content panels; the boot <script> right after the wrapper sets the
initial active tab so the first paint matches the requested state.
Usage:
{% from "components/tabs.html" import tabs, tab_trigger, tab_content %}
{% call tabs(id="account-tabs", value="account") %}
{% from "components/tabs.html" import tabs_list_open, tabs_list_close %}
{{ tabs_list_open() }}
{{ tab_trigger("account", "Account") }}
{{ tab_trigger("password", "Password") }}
{{ tabs_list_close() }}
{% call(_) tab_content("account") %}
<p>Account fields…</p>
{% endcall %}
{% call(_) tab_content("password") %}
<p>Password fields…</p>
{% endcall %}
{% endcall %} #}
{% macro tabs(id, value, orientation="horizontal", activation="automatic", extra_class="") %}
<div id="{{ id }}"
data-slot="tabs"
data-tabs
data-orientation="{{ orientation }}"
data-activation="{{ activation }}"
data-active-tab="{{ value }}"
class="group/tabs flex gap-2 data-[orientation=horizontal]:flex-col {{ extra_class }}">
{{ caller() }}
</div>
<script>(function(el){
var active = el.getAttribute('data-active-tab');
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
p.id = el.id + '-panel-' + p.getAttribute('data-tab-panel');
});
el.querySelectorAll('[data-tab-trigger]').forEach(function(t){
var value = t.getAttribute('data-tab-trigger');
var on = value === active;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.setAttribute('tabindex', on ? '0' : '-1');
t.setAttribute('aria-controls', el.id + '-panel-' + value);
t.id = el.id + '-trigger-' + value;
});
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
var value = p.getAttribute('data-tab-panel');
var on = value === active;
if (on) p.removeAttribute('hidden'); else p.setAttribute('hidden', '');
p.setAttribute('aria-labelledby', el.id + '-trigger-' + value);
});
el.setAttribute('data-tabs-ready','true');
})(document.currentScript.previousElementSibling);</script>
{% endmacro %}
{# APG prefers aria_labelledby pointing at a visible heading when one exists;
aria_label is the fallback for an unlabelled tablist. See
repos/aria-practices/content/patterns/tabs/tabs-pattern.html:129-130. #}
{% macro tabs_list_open(aria_label=none, aria_labelledby=none, extra_class="") -%}
<div role="tablist"
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
data-slot="tabs-list"
class="inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col {{ extra_class }}">
{%- endmacro %}
{% macro tabs_list_close() %}</div>{% endmacro %}
{# **attrs passes hx-* and other attributes through onto the tab button
(underscores become dashes), mirroring how button.html forwards attrs. This
is the idiomatic host for the htmx lazy-load pattern (hx_get + hx_trigger=
"click once" to fetch the panel only when the tab is activated); panels
render hidden so hooks there fire on load. See
repos/htmx/www/src/content/patterns/01-loading/03-lazy-load.md. #}
{% macro tab_trigger(value, label, disabled=false, extra_class="", **attrs) %}
<button type="button"
role="tab"
data-slot="tabs-trigger"
data-tab-trigger="{{ value }}"
{% if disabled %} disabled{% endif %}
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor %}
class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 {{ extra_class }}">{{ label }}</button>
{% endmacro %}
{% macro tab_content(value, extra_class="") %}
<div role="tabpanel"
data-slot="tabs-content"
data-tab-panel="{{ value }}"
tabindex="0"
class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 {{ extra_class }}">
{{ caller() }}
</div>
{% endmacro %}
1. Save the file
Add tabs.tmpl alongside button.tmpl.
2. Use it
{{template "tabs" (dict
"ID" "account-tabs" "Active" "account"
"Body" (htmlSafe `<div role="tablist" class="…">
{{template "tabs_trigger" (dict "Value" "account" "Label" "Account")}}
{{template "tabs_trigger" (dict "Value" "password" "Label" "Password")}}
</div>
{{template "tabs_content" (dict "Value" "account" "Body" (htmlSafe "Account fields…"))}}
{{template "tabs_content" (dict "Value" "password" "Body" (htmlSafe "Password fields…"))}}`)
)}}View source
{{/*
Tabs template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/tabs.tsx. Provides three named templates:
- "tabs" — the wrapper + boot script
- "tabs_list" — opens / closes the role="tablist" container
- "tabs_trigger" — one tab button
- "tabs_content" — one panel
Usage (compose your own block of HTML inside):
type TabsArgs struct {
ID, Active, Orientation string
Body template.HTML
}
Hand-compose the inner HTML so you can build the trigger list + panels.
*/}}
{{define "tabs"}}
{{- $orientation := or .Orientation "horizontal" -}}
{{- $activation := or .Activation "automatic" -}}
<div id="{{.ID}}"
data-slot="tabs"
data-tabs
data-orientation="{{$orientation}}"
data-activation="{{$activation}}"
data-active-tab="{{.Active}}"
class="group/tabs flex gap-2 data-[orientation=horizontal]:flex-col">
{{.Body}}
</div>
<script>(function(el){
var active = el.getAttribute('data-active-tab');
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
p.id = el.id + '-panel-' + p.getAttribute('data-tab-panel');
});
el.querySelectorAll('[data-tab-trigger]').forEach(function(t){
var value = t.getAttribute('data-tab-trigger');
var on = value === active;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.setAttribute('tabindex', on ? '0' : '-1');
t.setAttribute('aria-controls', el.id + '-panel-' + value);
t.id = el.id + '-trigger-' + value;
});
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
var value = p.getAttribute('data-tab-panel');
var on = value === active;
if (on) p.removeAttribute('hidden'); else p.setAttribute('hidden', '');
p.setAttribute('aria-labelledby', el.id + '-trigger-' + value);
});
el.setAttribute('data-tabs-ready','true');
})(document.currentScript.previousElementSibling);</script>
{{end}}
{{/*
tabs_trigger args: { Value, Label string; Disabled bool; Attrs map[string]string }
.Attrs (e.g. {"hx-get": "/tab/account", "hx-target": "#panel", "hx-trigger": "click once"})
ranges onto the tab button, mirroring button.tmpl. This is the idiomatic host
for the htmx lazy-load pattern — fetch the panel only when the tab is
activated; panels render hidden so hooks there fire on load. See
repos/htmx/www/src/content/patterns/01-loading/03-lazy-load.md.
*/}}
{{define "tabs_trigger"}}
<button type="button"
role="tab"
data-slot="tabs-trigger"
data-tab-trigger="{{.Value}}"
{{- if .Disabled}} disabled{{end}}
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end}}
class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">{{.Label}}</button>
{{end}}
{{define "tabs_content"}}
<div role="tabpanel"
data-slot="tabs-content"
data-tab-panel="{{.Value}}"
tabindex="0"
class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">{{.Body}}</div>
{{end}}
1. Save the file
Drop tabs.ex into lib/my_app_web/components/.
2. Use it
<.tabs id="account-tabs" value="account">
<.tabs_list>
<.tabs_trigger value="account">Account</.tabs_trigger>
<.tabs_trigger value="password">Password</.tabs_trigger>
</.tabs_list>
<.tabs_content value="account">Account fields…</.tabs_content>
<.tabs_content value="password">Password fields…</.tabs_content>
</.tabs>View source
defmodule ShadcnHtmx.Components.Tabs do
@moduledoc """
Tabs — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/tabs.tsx. Three function components: `tabs`,
`tabs_list`, `tabs_trigger`, `tabs_content`. The keyboard contract
(arrows, Home, End) is wired up in public/site.js.
## Examples
<.tabs id="account-tabs" value="account">
<.tabs_list>
<.tabs_trigger value="account">Account</.tabs_trigger>
<.tabs_trigger value="password">Password</.tabs_trigger>
</.tabs_list>
<.tabs_content value="account">
<p>Account fields…</p>
</.tabs_content>
<.tabs_content value="password">
<p>Password fields…</p>
</.tabs_content>
</.tabs>
"""
use Phoenix.Component
attr :id, :string, required: true
attr :value, :string, required: true
attr :orientation, :string, default: "horizontal", values: ~w(horizontal vertical)
# APG activation mode — "automatic" (focus = activate) or "manual" (focus
# only; Space/Enter to activate). See
# repos/aria-practices/content/patterns/tabs/tabs-pattern.html:49,104-107.
attr :activation, :string, default: "automatic", values: ~w(automatic manual)
attr :class, :string, default: nil
slot :inner_block, required: true
def tabs(assigns) do
~H"""
<div
id={@id}
data-slot="tabs"
data-tabs
data-orientation={@orientation}
data-activation={@activation}
data-active-tab={@value}
class={["group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", @class]}
>
{render_slot(@inner_block)}
</div>
<script>{Phoenix.HTML.raw(~s"""
(function(el){
var active = el.getAttribute('data-active-tab');
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
p.id = el.id + '-panel-' + p.getAttribute('data-tab-panel');
});
el.querySelectorAll('[data-tab-trigger]').forEach(function(t){
var value = t.getAttribute('data-tab-trigger');
var on = value === active;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.setAttribute('tabindex', on ? '0' : '-1');
t.setAttribute('aria-controls', el.id + '-panel-' + value);
t.id = el.id + '-trigger-' + value;
});
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
var value = p.getAttribute('data-tab-panel');
var on = value === active;
if (on) p.removeAttribute('hidden'); else p.setAttribute('hidden', '');
p.setAttribute('aria-labelledby', el.id + '-trigger-' + value);
});
el.setAttribute('data-tabs-ready','true');
})(document.currentScript.previousElementSibling);
""")}</script>
"""
end
attr :class, :string, default: nil
attr :"aria-label", :string, default: nil
# APG prefers aria-labelledby pointing at a visible heading when one exists;
# aria-label is the fallback for an unlabelled tablist. See
# repos/aria-practices/content/patterns/tabs/tabs-pattern.html:129-130.
attr :"aria-labelledby", :string, default: nil
slot :inner_block, required: true
def tabs_list(assigns) do
~H"""
<div
role="tablist"
aria-label={assigns[:"aria-label"]}
aria-labelledby={assigns[:"aria-labelledby"]}
data-slot="tabs-list"
class={[
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
"group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
@class
]}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :value, :string, required: true
attr :disabled, :boolean, default: false
attr :class, :string, default: nil
# htmx and arbitrary attributes ride along onto the underlying tab button,
# mirroring tabs_content. This is the idiomatic host for the htmx lazy-load
# pattern (hx-get + hx-trigger="click once" to fetch the panel only when the
# tab is activated), since panels render hidden so hooks there fire on load.
# See repos/htmx/www/src/content/patterns/01-loading/03-lazy-load.md.
attr :rest, :global,
include:
~w(hx-get hx-post hx-put hx-patch hx-delete hx-target hx-swap hx-trigger hx-indicator hx-confirm hx-vals hx-disable
id aria-label aria-labelledby aria-describedby aria-haspopup)
slot :inner_block, required: true
def tabs_trigger(assigns) do
~H"""
<button
type="button"
role="tab"
data-slot="tabs-trigger"
data-tab-trigger={@value}
disabled={@disabled}
{@rest}
class={[
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all",
"hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-50",
"aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm",
"dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground",
"group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start",
@class
]}
>
{render_slot(@inner_block)}
</button>
"""
end
attr :value, :string, required: true
attr :class, :string, default: nil
slot :inner_block, required: true
def tabs_content(assigns) do
~H"""
<div
role="tabpanel"
data-slot="tabs-content"
data-tab-panel={@value}
tabindex="0"
class={[
"flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
@class
]}
>
{render_slot(@inner_block)}
</div>
"""
end
end
1. Save the file
Includes the boot + keyboard script inline.
2. Use it
<div id="account-tabs" data-tabs data-active-tab="account"
data-orientation="horizontal" class="group/tabs flex flex-col gap-2">
<div role="tablist" class="…tabs-list classes…">
<button role="tab" data-tab-trigger="account" class="…">Account</button>
<button role="tab" data-tab-trigger="password" class="…">Password</button>
</div>
<div role="tabpanel" data-tab-panel="account">Account fields…</div>
<div role="tabpanel" data-tab-panel="password">Password fields…</div>
</div>
<script>/* see snippets/tabs.html for the boot + keyboard wiring */</script>View source
<!--
shadcn-htmx — raw HTML tabs snippet.
Mirrors registry/ui/tabs.tsx. The inline <script> right after the wrapper
applies the active state on first paint (no flicker). The keyboard
contract (arrows / Home / End) needs the wiring in public/site.js.
Required CSS theme variables: --background, --foreground, --muted,
--muted-foreground, --ring, --input. See app/styles/input.css.
Labelling the tablist (WAI-ARIA APG): if a visible heading exists above the
tabs, point at it with aria-labelledby instead of aria-label, e.g.
<h2 id="settings-heading">Settings</h2> then
<div role="tablist" aria-labelledby="settings-heading" …>. Use aria-label only
when there is no visible label. See
repos/aria-practices/content/patterns/tabs/tabs-pattern.html:129-130.
Lazy-loading a panel on tab activation (htmx): put the hx-* hook on the tab
button (NOT the panel — panels render hidden, so a hook there fires on page
load), e.g. <button role="tab" … hx-get="/tab/account" hx-target="#…-panel-account"
hx-trigger="click once">. See
repos/htmx/www/src/content/patterns/01-loading/03-lazy-load.md.
Minimal inline JS for keyboard navigation:
<script>
document.addEventListener('keydown', (e) => {
const t = e.target.closest('[data-tab-trigger]')
if (!t) return
const group = t.closest('[data-tabs]')
const orientation = group.getAttribute('data-orientation') || 'horizontal'
const prev = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft'
const next = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight'
if (![prev, next, 'Home', 'End'].includes(e.key)) return
e.preventDefault()
const list = [...group.querySelectorAll('[data-tab-trigger]:not([disabled])')]
const i = list.indexOf(t)
const target =
e.key === prev ? list[(i - 1 + list.length) % list.length] :
e.key === next ? list[(i + 1) % list.length] :
e.key === 'Home' ? list[0] : list[list.length - 1]
target.focus()
target.click()
})
</script>
-->
<div id="account-tabs"
data-slot="tabs"
data-tabs
data-orientation="horizontal"
data-activation="automatic"
data-active-tab="account"
class="group/tabs flex flex-col gap-2 data-[orientation=horizontal]:flex-col">
<div role="tablist" data-slot="tabs-list"
class="inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground">
<button type="button" role="tab"
data-slot="tabs-trigger"
data-tab-trigger="account"
class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm">
Account
</button>
<button type="button" role="tab"
data-slot="tabs-trigger"
data-tab-trigger="password"
class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm">
Password
</button>
</div>
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="account" tabindex="0"
class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
<!-- Account fields -->
</div>
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="password" tabindex="0"
class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
<!-- Password fields -->
</div>
</div>
<script>
(function (el) {
var active = el.getAttribute('data-active-tab')
el.querySelectorAll('[data-tab-panel]').forEach(function (p) {
p.id = el.id + '-panel-' + p.getAttribute('data-tab-panel')
})
el.querySelectorAll('[data-tab-trigger]').forEach(function (t) {
var value = t.getAttribute('data-tab-trigger')
var on = value === active
t.setAttribute('aria-selected', on ? 'true' : 'false')
t.setAttribute('tabindex', on ? '0' : '-1')
t.setAttribute('aria-controls', el.id + '-panel-' + value)
t.id = el.id + '-trigger-' + value
})
el.querySelectorAll('[data-tab-panel]').forEach(function (p) {
var value = p.getAttribute('data-tab-panel')
var on = value === active
if (on) p.removeAttribute('hidden'); else p.setAttribute('hidden', '')
p.setAttribute('aria-labelledby', el.id + '-trigger-' + value)
})
el.setAttribute('data-tabs-ready', 'true')
})(document.currentScript.previousElementSibling)
</script>
Examples
Basic — click or arrow-keys
Click a tab. Use ←/→ to move between them; Home / End jump to first / last. Tab moves focus out of the tablist into the active panel.
APG's tabs pattern: focus enters the tablist on the active tab. Arrow keys move focus AND switch the active tab in one step (auto-activation — the alternative, manual activation, is rarer in practice). The active tab gets aria-selected="true" and tabindex="0"; all others get tabindex="-1" so Tab leaves the strip after one stop.
You're subscribed to weekly digests and security alerts.
<Tabs id="account-tabs" value="account">
<TabsList ariaLabel="Account sections">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
</TabsList>
<TabsContent value="account">…</TabsContent>
<TabsContent value="password">…</TabsContent>
<TabsContent value="notifications">…</TabsContent>
</Tabs>{% call tabs(id="account-tabs", value="account") %}
{{ tabs_list_open(aria_label="Account sections") }}
{{ tab_trigger("account", "Account") }}
{{ tab_trigger("password", "Password") }}
{{ tab_trigger("notifications", "Notifications") }}
{{ tabs_list_close() }}
{% call(_) tab_content("account") %}…{% endcall %}
{% call(_) tab_content("password") %}…{% endcall %}
{% call(_) tab_content("notifications") %}…{% endcall %}
{% endcall %}{{template "tabs" (dict "ID" "account-tabs" "Active" "account"
"Body" (htmlSafe `…tablist + triggers + panels…`))}}<.tabs id="account-tabs" value="account">
<.tabs_list aria-label="Account sections">
<.tabs_trigger value="account">Account</.tabs_trigger>
<.tabs_trigger value="password">Password</.tabs_trigger>
<.tabs_trigger value="notifications">Notifications</.tabs_trigger>
</.tabs_list>
<.tabs_content value="account">…</.tabs_content>
<.tabs_content value="password">…</.tabs_content>
<.tabs_content value="notifications">…</.tabs_content>
</.tabs><div id="ex-basic-tabs" data-slot="tabs" data-tabs="true" data-orientation="horizontal" data-activation="automatic" data-active-tab="account" class="group/tabs flex gap-2 data-[orientation=horizontal]:flex-col w-full max-w-md">
<div role="tablist" aria-label="Account sections" data-slot="tabs-list" class="inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col">
<button type="button" role="tab" data-slot="tabs-trigger" data-tab-trigger="account" class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Account</button>
<button type="button" role="tab" data-slot="tabs-trigger" data-tab-trigger="password" class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Password</button>
<button type="button" role="tab" data-slot="tabs-trigger" data-tab-trigger="notifications" class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Notifications</button>
</div>
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="account" tabindex="0" class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-md border bg-background p-4">
<div class="grid gap-3">
<label for="ex-basic-name" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Name</label>
<input type="text" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&.htmx-request]:opacity-70" data-slot="input" id="ex-basic-name" defaultValue="Mehmet"/>
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5 justify-self-start" data-slot="button" data-variant="default" data-size="sm">Save</button>
</div>
</div>
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="password" tabindex="0" class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-md border bg-background p-4">
<div class="grid gap-3">
<label for="ex-basic-pw" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">New password</label>
<input type="password" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&.htmx-request]:opacity-70" data-slot="input" id="ex-basic-pw"/>
<button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5 justify-self-start" data-slot="button" data-variant="default" data-size="sm">Change</button>
</div>
</div>
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="notifications" tabindex="0" class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-md border bg-background p-4">
<p class="text-sm text-muted-foreground">You're subscribed to weekly digests and security alerts.</p>
</div>
</div>
<script>
(function(el){
var active = el.getAttribute('data-active-tab');
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
var value = p.getAttribute('data-tab-panel');
p.id = el.id + '-panel-' + value;
});
el.querySelectorAll('[data-tab-trigger]').forEach(function(t){
var value = t.getAttribute('data-tab-trigger');
var on = value === active;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.setAttribute('tabindex', on ? '0' : '-1');
t.setAttribute('aria-controls', el.id + '-panel-' + value);
t.id = el.id + '-trigger-' + value;
});
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
var value = p.getAttribute('data-tab-panel');
var on = value === active;
if (on) p.removeAttribute('hidden'); else p.setAttribute('hidden', '');
p.setAttribute('aria-labelledby', el.id + '-trigger-' + value);
});
var list = el.querySelector('[role="tablist"]');
if (list) list.setAttribute('aria-orientation', el.getAttribute('data-orientation') === 'vertical' ? 'vertical' : 'horizontal');
el.setAttribute('data-tabs-ready','true');
})(document.currentScript.previousElementSibling);
</script>Further reading
Vertical orientation
Set orientation="vertical" and the tablist stacks vertically. Up/Down arrows move; everything else carries over.
The orientation switch is one prop. The keyboard handler picks the right axis automatically (Up/Down for vertical, Left/Right for horizontal). Useful for settings sidebars and email clients.
<Tabs id="settings" value="general" orientation="vertical">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent value="general">…</TabsContent>
…
</Tabs>{% call tabs(id="settings", value="general", orientation="vertical") %}
{{ tabs_list_open() }}
{{ tab_trigger("general", "General") }}
{{ tab_trigger("security", "Security") }}
{{ tab_trigger("billing", "Billing") }}
{{ tabs_list_close() }}
…
{% endcall %}{{template "tabs" (dict
"ID" "settings" "Active" "general" "Orientation" "vertical"
"Body" (htmlSafe `…`))}}<.tabs id="settings" value="general" orientation="vertical">
<.tabs_list>
<.tabs_trigger value="general">General</.tabs_trigger>
…
</.tabs_list>
…
</.tabs><div id="ex-vert-tabs" data-slot="tabs" data-tabs="true" data-orientation="vertical" data-activation="automatic" data-active-tab="general" class="group/tabs flex gap-2 data-[orientation=horizontal]:flex-col w-full max-w-md flex-row! gap-4!">
<div role="tablist" data-slot="tabs-list" class="inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col flex flex-col! h-auto! items-stretch!">
<button type="button" role="tab" data-slot="tabs-trigger" data-tab-trigger="general" class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">General</button>
<button type="button" role="tab" data-slot="tabs-trigger" data-tab-trigger="security" class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Security</button>
<button type="button" role="tab" data-slot="tabs-trigger" data-tab-trigger="billing" class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Billing</button>
</div>
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="general" tabindex="0" class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-md border bg-background p-4 text-sm">General app preferences.</div>
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="security" tabindex="0" class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-md border bg-background p-4 text-sm">2-factor, sessions, audit log.</div>
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="billing" tabindex="0" class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-md border bg-background p-4 text-sm">Plan, invoices, payment method.</div>
</div>
<script>
(function(el){
var active = el.getAttribute('data-active-tab');
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
var value = p.getAttribute('data-tab-panel');
p.id = el.id + '-panel-' + value;
});
el.querySelectorAll('[data-tab-trigger]').forEach(function(t){
var value = t.getAttribute('data-tab-trigger');
var on = value === active;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.setAttribute('tabindex', on ? '0' : '-1');
t.setAttribute('aria-controls', el.id + '-panel-' + value);
t.id = el.id + '-trigger-' + value;
});
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
var value = p.getAttribute('data-tab-panel');
var on = value === active;
if (on) p.removeAttribute('hidden'); else p.setAttribute('hidden', '');
p.setAttribute('aria-labelledby', el.id + '-trigger-' + value);
});
var list = el.querySelector('[role="tablist"]');
if (list) list.setAttribute('aria-orientation', el.getAttribute('data-orientation') === 'vertical' ? 'vertical' : 'horizontal');
el.setAttribute('data-tabs-ready','true');
})(document.currentScript.previousElementSibling);
</script>Further reading
htmx — lazy tab content
Each panel fetches its content the first time it's revealed. hx-trigger="intersect once" only fires when the panel becomes visible.
When tab panels carry heavy content, don't render all of them on the initial page. Use hx-trigger="intersect once" on the panel — htmx fires the request the first time the element enters the viewport (which, with our boot script, is when the tab is first activated). Subsequent visits to the tab show the cached content. Combine with hx-swap="innerHTML" and a loading skeleton for a smooth feel.
<TabsContent value="comments"
hx-get="/api/comments"
hx-trigger="intersect once"
hx-swap="innerHTML">
<span class="text-muted-foreground">Loading…</span>
</TabsContent>{# tab_content has no hx-* passthrough — hand-write the panel: #}
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="comments" tabindex="0"
class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
hx-get="/api/comments" hx-trigger="intersect once" hx-swap="innerHTML">
Loading…
</div><div role="tabpanel" data-tab-panel="comments"
hx-get="/api/comments" hx-trigger="intersect once" hx-swap="innerHTML">
Loading…
</div><.tabs_content value="comments"
hx-get={~p"/api/comments"}
hx-trigger="intersect once"
hx-swap="innerHTML">
Loading…
</.tabs_content><div id="ex-htmx-tabs" data-slot="tabs" data-tabs="true" data-orientation="horizontal" data-activation="automatic" data-active-tab="overview" class="group/tabs flex gap-2 data-[orientation=horizontal]:flex-col w-full max-w-md">
<div role="tablist" data-slot="tabs-list" class="inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col">
<button type="button" role="tab" data-slot="tabs-trigger" data-tab-trigger="overview" class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Overview</button>
<button type="button" role="tab" data-slot="tabs-trigger" data-tab-trigger="comments" class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Comments</button>
<button type="button" role="tab" data-slot="tabs-trigger" data-tab-trigger="history" class="relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-sm dark:aria-selected:border-input dark:aria-selected:bg-input/30 dark:aria-selected:text-foreground group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">History</button>
</div>
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="overview" tabindex="0" class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-md border bg-background p-4 text-sm">Static content rendered with the page.</div>
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="comments" tabindex="0" class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-md border bg-background p-4 text-sm" hx-get="/tabs/comments" hx-trigger="intersect once" hx-swap="innerHTML">
<span class="text-muted-foreground">Loading…</span>
</div>
<div role="tabpanel" data-slot="tabs-content" data-tab-panel="history" tabindex="0" class="flex-1 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-md border bg-background p-4 text-sm" hx-get="/tabs/history" hx-trigger="intersect once" hx-swap="innerHTML">
<span class="text-muted-foreground">Loading…</span>
</div>
</div>
<script>
(function(el){
var active = el.getAttribute('data-active-tab');
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
var value = p.getAttribute('data-tab-panel');
p.id = el.id + '-panel-' + value;
});
el.querySelectorAll('[data-tab-trigger]').forEach(function(t){
var value = t.getAttribute('data-tab-trigger');
var on = value === active;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.setAttribute('tabindex', on ? '0' : '-1');
t.setAttribute('aria-controls', el.id + '-panel-' + value);
t.id = el.id + '-trigger-' + value;
});
el.querySelectorAll('[data-tab-panel]').forEach(function(p){
var value = p.getAttribute('data-tab-panel');
var on = value === active;
if (on) p.removeAttribute('hidden'); else p.setAttribute('hidden', '');
p.setAttribute('aria-labelledby', el.id + '-trigger-' + value);
});
var list = el.querySelector('[role="tablist"]');
if (list) list.setAttribute('aria-orientation', el.getAttribute('data-orientation') === 'vertical' ? 'vertical' : 'horizontal');
el.setAttribute('data-tabs-ready','true');
})(document.currentScript.previousElementSibling);
</script>Further reading
API Reference
<Tabs>
| Prop | Type | Default | Description |
|---|---|---|---|
ariaLabelledby | string | — | TabsList: id of a visible heading that labels the tablist. APG prefers this over ariaLabel when a visible label exists; use ariaLabel only when there is no visible label.APGTabs labelling rule |
hx-* / data-* / aria-* | string | — | TabsTrigger: extra attributes forwarded onto the underlying tab button. The idiomatic host for the htmx lazy-load pattern (hx-get + hx-trigger="click once") so a panel is fetched only when its tab is activated; fixed contract attributes (type, role, data-slot, data-tab-trigger, class) cannot be overridden.htmxLazy loading |
id* | string | — | Used to scope keyboard handlers + assign per-trigger ids. |
value* | string | — | Initial active tab's value. |
orientation | "horizontal"|"vertical" | "horizontal" | Layout + aria-orientation + arrow-key axis. |
activation | "automatic"|"manual" | "automatic" | automatic: arrow keys also activate. manual: Space/Enter to activate.APGManual vs automatic activation |
class | string | — | Extra Tailwind classes appended to the root element. |
* required