shshadcn-htmx

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.json

2. Use it

components/ui/tabs.tsx
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
components/ui/tabs.tsx
/** @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

templates/components/tabs.html
{% 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
templates/components/tabs.html
{# 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

templates/components/tabs.tmpl
{{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
templates/components/tabs.tmpl
{{/*
  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

lib/my_app_web/components/tabs.ex
<.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
lib/my_app_web/components/tabs.ex
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

index.html
<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
index.html
<!--
  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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;.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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 rounded-md px-3 has-[&gt;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 [&amp;.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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 rounded-md px-3 has-[&gt;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&#39;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>

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.

General app preferences.
2-factor, sessions, audit log.
Plan, invoices, payment method.
<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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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>

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.

Static content rendered with the page.
Loading…
Loading…
<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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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>

API Reference

<Tabs>

PropTypeDefaultDescription
ariaLabelledbystring
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
classstring
Extra Tailwind classes appended to the root element.

* required