Tabs

Tab interface with three visual variants, three sizes, and keyboard navigation.

Basic

Snapshot of the workspace with health, ownership, and recent updates.
.heex
<.tabs id="basic-tabs" class="w-full max-w-md">
  <.tabs_list active_tab="basic-overview">
    <:tab name="basic-overview">Overview</:tab>
    <:tab name="basic-activity">Activity</:tab>
    <:tab name="basic-members">Members</:tab>
  </.tabs_list>

  <.tabs_panel name="basic-overview" active class="pt-4 text-sm text-foreground-soft">
    Snapshot of the workspace with health, ownership, and recent updates.
  </.tabs_panel>
  <.tabs_panel name="basic-activity" class="pt-4 text-sm text-foreground-soft">
    Timeline of edits, comments, and merges across the team.
  </.tabs_panel>
  <.tabs_panel name="basic-members" class="pt-4 text-sm text-foreground-soft">
    Roster of teammates, roles, and pending invitations.
  </.tabs_panel>
</.tabs>

Variants

Three visual treatments. default is an underlined header strip; segmented is a connected pill of buttons; ghost is minimal in-card chrome.

.heex
<div class="space-y-8 w-full max-w-md">
  <.tabs id="variants-default">
    <.tabs_list variant="default" active_tab="variants-default-day">
      <:tab name="variants-default-day">Day</:tab>
      <:tab name="variants-default-week">Week</:tab>
      <:tab name="variants-default-month">Month</:tab>
    </.tabs_list>
  </.tabs>

  <.tabs id="variants-segmented">
    <.tabs_list variant="segmented" active_tab="variants-segmented-day">
      <:tab name="variants-segmented-day">Day</:tab>
      <:tab name="variants-segmented-week">Week</:tab>
      <:tab name="variants-segmented-month">Month</:tab>
    </.tabs_list>
  </.tabs>

  <.tabs id="variants-ghost">
    <.tabs_list variant="ghost" active_tab="variants-ghost-day">
      <:tab name="variants-ghost-day">Day</:tab>
      <:tab name="variants-ghost-week">Week</:tab>
      <:tab name="variants-ghost-month">Month</:tab>
    </.tabs_list>
  </.tabs>
</div>

Sizes

Heights match Fluxon.Components.Button sizes so a tab strip can sit flush next to buttons of the same size.

.heex
<div class="space-y-6 w-full max-w-md">
  <.tabs id="sizes-xs">
    <.tabs_list size="xs" variant="segmented" active_tab="sizes-xs-day">
      <:tab name="sizes-xs-day">Day</:tab>
      <:tab name="sizes-xs-week">Week</:tab>
      <:tab name="sizes-xs-month">Month</:tab>
    </.tabs_list>
  </.tabs>

  <.tabs id="sizes-sm">
    <.tabs_list size="sm" variant="segmented" active_tab="sizes-sm-day">
      <:tab name="sizes-sm-day">Day</:tab>
      <:tab name="sizes-sm-week">Week</:tab>
      <:tab name="sizes-sm-month">Month</:tab>
    </.tabs_list>
  </.tabs>

  <.tabs id="sizes-md">
    <.tabs_list size="md" variant="segmented" active_tab="sizes-md-day">
      <:tab name="sizes-md-day">Day</:tab>
      <:tab name="sizes-md-week">Week</:tab>
      <:tab name="sizes-md-month">Month</:tab>
    </.tabs_list>
  </.tabs>
</div>

With icons

Add class="icon" to icon elements so the size variants resize them to match the tab text.

.heex
<.tabs id="dashboard-tabs">
  <.tabs_list active_tab="dashboard">
    <:tab name="dashboard">
      <.icon name="hero-home" class="icon" /> Dashboard
    </:tab>
    <:tab name="projects">
      <.icon name="hero-folder" class="icon" /> Projects
    </:tab>
    <:tab name="team">
      <.icon name="hero-users" class="icon" /> Team
    </:tab>
    <:tab name="settings">
      <.icon name="hero-cog-6-tooth" class="icon" /> Settings
    </:tab>
  </.tabs_list>
</.tabs>

With badges and counts

Tab content is arbitrary HEEx, so labels can compose icons, counts, or any inline element.

.heex
<.tabs id="inbox-tabs">
  <.tabs_list active_tab="inbox">
    <:tab name="inbox">
      <.icon name="hero-envelope" class="icon" /> Inbox
      <.badge color="primary" size="sm">12</.badge>
    </:tab>
    <:tab name="threads">
      <.icon name="hero-chat-bubble-left-right" class="icon" /> Threads
      <.badge size="sm">3</.badge>
    </:tab>
    <:tab name="archived">
      <.icon name="hero-archive-box" class="icon" /> Archived
    </:tab>
  </.tabs_list>
</.tabs>

Disabled tabs

Use disabled or aria-disabled="true" to skip a tab in click and arrow-key navigation.

.heex
<.tabs id="disabled-tabs" class="w-full max-w-md">
  <.tabs_list active_tab="disabled-profile" class="overflow-x-auto whitespace-nowrap">
    <:tab name="disabled-profile">Profile</:tab>
    <:tab name="disabled-billing" disabled>Billing</:tab>
    <:tab name="disabled-team">Team</:tab>
    <:tab name="disabled-audit" aria-disabled="true">Audit log</:tab>
  </.tabs_list>
</.tabs>

Auto-sized segmented

Segmented and ghost tabs stretch to fill the row by default. Pass class="flex-none" on a tab to size it to its content instead.

.heex
<.tabs id="auto-tabs">
  <.tabs_list variant="segmented" active_tab="all">
    <:tab class="flex-none" name="all">All</:tab>
    <:tab class="flex-none" name="active">Active</:tab>
    <:tab class="flex-none" name="paused">Paused</:tab>
  </.tabs_list>
</.tabs>

Custom panel layout

tabs_panel ships with no padding so panels sit flush to the tablist. Use class for whatever layout the panel needs.

Plan
Team
Seats used
8 / 12
Region
eu-west-2
Renewal
Mar 14, 2026
.heex
<.tabs id="custom-panels-tabs" class="w-full max-w-lg">
  <.tabs_list variant="default" active_tab="custom-overview">
    <:tab name="custom-overview">Overview</:tab>
    <:tab name="custom-stats">Stats</:tab>
  </.tabs_list>

  <.tabs_panel
    name="custom-overview"
    active
    class="grid grid-cols-2 gap-4 p-4 mt-4 rounded-base border border-base bg-sunken"
  >
    <div>
      <div class="text-xs text-foreground-softer">Plan</div>
      <div class="text-sm font-medium">Team</div>
    </div>
    <div>
      <div class="text-xs text-foreground-softer">Seats used</div>
      <div class="text-sm font-medium">8 / 12</div>
    </div>
    <div>
      <div class="text-xs text-foreground-softer">Region</div>
      <div class="text-sm font-medium">eu-west-2</div>
    </div>
    <div>
      <div class="text-xs text-foreground-softer">Renewal</div>
      <div class="text-sm font-medium">Mar 14, 2026</div>
    </div>
  </.tabs_panel>

  <.tabs_panel
    name="custom-stats"
    class="grid grid-cols-3 gap-3 p-4 mt-4 rounded-base border border-base bg-sunken"
  >
    <div>
      <div class="text-xs text-foreground-softer">Requests</div>
      <div class="text-base font-semibold">1.2M</div>
    </div>
    <div>
      <div class="text-xs text-foreground-softer">Errors</div>
      <div class="text-base font-semibold">0.04%</div>
    </div>
    <div>
      <div class="text-xs text-foreground-softer">P95</div>
      <div class="text-base font-semibold">142ms</div>
    </div>
  </.tabs_panel>
</.tabs>

Server-driven active tab

Drive both active_tab on the list and active on each panel from the same assign so server and client state stay aligned across patches.

Manage display name, avatar, and contact preferences.

Active tab: profile • server events received: 0

.heex
<.tabs id="account-tabs">
  <.tabs_list active_tab={@active_tab}>
    <:tab name="profile" phx-click={JS.push("set_tab", value: %{tab: "profile"})}>
      <.icon name="huge-user" class="icon" /> Profile
    </:tab>
    <:tab name="security" phx-click={JS.push("set_tab", value: %{tab: "security"})}>
      <.icon name="huge-lock-01" class="icon" /> Security
    </:tab>
    <:tab name="billing" phx-click={JS.push("set_tab", value: %{tab: "billing"})}>
      <.icon name="huge-credit-card" class="icon" /> Billing
    </:tab>
  </.tabs_list>

  <.tabs_panel name="profile" active={@active_tab == "profile"}>
    Manage display name, avatar, and contact preferences.
  </.tabs_panel>
  <.tabs_panel name="security" active={@active_tab == "security"}>
    Two-factor authentication, sessions, and recovery codes.
  </.tabs_panel>
  <.tabs_panel name="billing" active={@active_tab == "billing"}>
    Plan, payment method, and invoices.
  </.tabs_panel>
</.tabs>

# In the parent LiveView:
def mount(_params, _session, socket) do
  {:ok, assign(socket, :active_tab, "profile")}
end

def handle_event("set_tab", %{"tab" => tab}, socket) do
  {:noreply, assign(socket, :active_tab, tab)}
end

Dynamic tabs

Pair :for on the tab slot with :for on the panels using the same source list. The controller re-wires aria attributes for added or reordered tabs on the next patch.

Project overview and setup instructions.
.heex
<.tabs id="docs-tabs">
  <.tabs_list size="sm" variant="default" active_tab={@active_doc}>
    <:tab
      :for={tab <- @tabs}
      name={tab.id}
      phx-click={JS.push("open_doc", value: %{slug: tab.id})}
    >
      {tab.title}
    </:tab>
  </.tabs_list>

  <.tabs_panel
    :for={tab <- @tabs}
    name={tab.id}
    active={@active_doc == tab.id}
  >
    {tab.body}
  </.tabs_panel>
</.tabs>

Nested tabs

Each tabs instance scopes its tablist and panel queries to its own container. Inner clicks never leak to the outer instance. Each container needs a unique id.

Name, avatar, and contact details.
.heex
<.tabs id="nested-outer" class="w-full max-w-lg">
  <.tabs_list variant="segmented" active_tab="nested-profile">
    <:tab name="nested-profile">Profile</:tab>
    <:tab name="nested-settings">Settings</:tab>
  </.tabs_list>

  <.tabs_panel name="nested-profile" active class="pt-4">
    <.tabs id="nested-inner">
      <.tabs_list variant="ghost" active_tab="nested-personal">
        <:tab name="nested-personal">Personal</:tab>
        <:tab name="nested-preferences">Preferences</:tab>
        <:tab name="nested-sessions">Sessions</:tab>
      </.tabs_list>

      <.tabs_panel name="nested-personal" active class="pt-3 text-sm text-foreground-soft">
        Name, avatar, and contact details.
      </.tabs_panel>
      <.tabs_panel name="nested-preferences" class="pt-3 text-sm text-foreground-soft">
        Theme, density, and keyboard shortcuts.
      </.tabs_panel>
      <.tabs_panel name="nested-sessions" class="pt-3 text-sm text-foreground-soft">
        Active devices and sign-in history.
      </.tabs_panel>
    </.tabs>
  </.tabs_panel>

  <.tabs_panel name="nested-settings" class="pt-4 text-sm text-foreground-soft">
    Workspace settings shared by everyone on the team.
  </.tabs_panel>
</.tabs>

In a card header

size="xs" variant="segmented" sits flush in a card toolbar as a compact view switcher.

Customers

Acme Co. Active
Globex Trial
Initech Churned
.heex
<section class="rounded-base border border-base overflow-hidden">
  <.tabs id="toolbar-tabs">
    <header class="flex items-center justify-between border-b border-base px-4 py-3">
      <h2 class="text-base font-semibold">Customers</h2>
      <.tabs_list size="xs" variant="segmented" active_tab="toolbar-list">
        <:tab class="flex-none" name="toolbar-list">
          <.icon name="hero-list-bullet" class="icon" /> List
        </:tab>
        <:tab class="flex-none" name="toolbar-grid">
          <.icon name="hero-squares-2x2" class="icon" /> Grid
        </:tab>
        <:tab class="flex-none" name="toolbar-table">
          <.icon name="hero-table-cells" class="icon" /> Table
        </:tab>
      </.tabs_list>
    </header>

    <.tabs_panel name="toolbar-list" active class="px-4 py-3">
      <%!-- List view content --%>
    </.tabs_panel>
    <.tabs_panel name="toolbar-grid" class="px-4 py-3">
      <%!-- Grid view content --%>
    </.tabs_panel>
    <.tabs_panel name="toolbar-table" class="px-4 py-3">
      <%!-- Table view content --%>
    </.tabs_panel>
  </.tabs>
</section>