Fluxon.Components.Tabs (Fluxon v3.1.2)

A tabbed interface for splitting related content into sibling panels that swap inside the same view.

Tabs is a compound component family with client-side behavior. The server renders a tablist of buttons and a matching set of panels, and the client keeps the selected tab and panel visibility in sync as the user clicks tabs or navigates with the keyboard. Inactive panels are hidden and pulled out of the layout without unmounting, so their DOM and any LiveComponent state inside them survive every tab switch.

The component family is composed of pieces that are designed to be used together:

  • tabs/1: the container that scopes a single tab instance and enables its client behavior. Required wrapper.
  • tabs_list/1: the tablist row that holds the interactive tab buttons. Owns the visual variant and size.
  • tabs_panel/1: the content panel rendered for one tab.

A typical structure looks like this:

tabs
 tabs_list
    :tab slot (button)
    :tab slot (button)
    :tab slot (button)
 tabs_panel (one per tab, matched by `name`)
 tabs_panel
 tabs_panel

Each :tab slot is paired to a tabs_panel by matching the slot's name against the panel's name attribute. That pairing determines which panel becomes visible when the tab is selected.

Choosing between tabs and navlist

Use tabs for in-page panel switching where the URL stays put and content swaps inside the same LiveView. Reach for Fluxon.Components.Navlist when the navigation actually changes the route (sidebars, docs indexes, settings menus). Tabs can also drive the URL via push_patch if you want shareable links to a specific panel; see "Server-Driven Active Tab" below.

Usage

A static tabbed interface with three panels and a hard-coded initial selection:

<.tabs id="account">
  <.tabs_list active_tab="profile">
    <:tab name="profile">Profile</:tab>
    <:tab name="settings">Settings</:tab>
    <:tab name="notifications">Notifications</:tab>
  </.tabs_list>

  <.tabs_panel name="profile" active>
    Profile content here.
  </.tabs_panel>

  <.tabs_panel name="settings">
    Settings content here.
  </.tabs_panel>

  <.tabs_panel name="notifications">
    Notifications content here.
  </.tabs_panel>
</.tabs>

Each :tab name must match exactly one tabs_panel name. The active tab is determined by active_tab on tabs_list for initial render and by the active boolean on tabs_panel for panel visibility. When LiveView is in charge, both should be derived from the same assign so they stay in lockstep across patches.

Match name exactly

A :tab whose name does not match any tabs_panel renders a button that controls nothing. Likewise, a tabs_panel without a matching tab is unreachable through the UI. The pairing is a string equality check on the name attribute, so typos silently break the link.

Visual Variants

The variant attribute on tabs_list selects between distinct visual treatments. Each variant ships with the styles for the underlying buttons too, so a single attribute switches the entire tablist look.

VariantTreatmentUse Case
defaultUnderlined tabs with a primary-colored bar under the active tab and a bottom border across the row.Page-level or section-level tabs where the row should feel like a navigation header.
segmentedA connected pill of buttons inside a bordered, recessed track with a raised active tab.Compact toolbars, view switchers, and segmented controls where tabs should read as a single grouped control.
ghostButton-like tabs with no chrome by default and a soft highlight on the active tab.In-card or in-panel tab strips where you want minimal visual weight.
<!-- Underlined tabs (default) -->
<.tabs id="default-tabs">
  <.tabs_list variant="default" active_tab="overview">
    <:tab name="overview">Overview</:tab>
    <:tab name="activity">Activity</:tab>
  </.tabs_list>
  <.tabs_panel name="overview" active>...</.tabs_panel>
  <.tabs_panel name="activity">...</.tabs_panel>
</.tabs>

<!-- Segmented control -->
<.tabs id="segmented-tabs">
  <.tabs_list variant="segmented" active_tab="day">
    <:tab name="day">Day</:tab>
    <:tab name="week">Week</:tab>
    <:tab name="month">Month</:tab>
  </.tabs_list>
  <.tabs_panel name="day" active>...</.tabs_panel>
  <.tabs_panel name="week">...</.tabs_panel>
  <.tabs_panel name="month">...</.tabs_panel>
</.tabs>

<!-- Ghost tabs -->
<.tabs id="ghost-tabs">
  <.tabs_list variant="ghost" active_tab="all">
    <:tab name="all">All</:tab>
    <:tab name="active">Active</:tab>
    <:tab name="archived">Archived</:tab>
  </.tabs_list>
  <.tabs_panel name="all" active>...</.tabs_panel>
  <.tabs_panel name="active">...</.tabs_panel>
  <.tabs_panel name="archived">...</.tabs_panel>
</.tabs>

segmented and ghost distribute width

Tabs in the segmented and ghost variants stretch each button to fill the row evenly (flex-1). The default variant lets buttons size to their content. If you want segmented or ghost tabs to size to content instead, override with class="flex-none" on the individual :tab slots.

Sizes

The size attribute on tabs_list aligns the tablist height with the corresponding button size, so a tab strip sits flush next to buttons of the same size in a toolbar. The size also controls the inner padding, font size, and icon size.

SizeHeightTextUse Case
xs28pxxsDense toolbars, inline switchers, table-cell controls.
sm32pxsmCompact panels, dialog headers, sidebar widgets.
md36pxsmDefault size for page-level and card-level tabs.
<.tabs_list size="xs" variant="segmented" active_tab="day">
  <:tab name="day">Day</:tab>
  <:tab name="week">Week</:tab>
</.tabs_list>

<.tabs_list size="sm" variant="default" active_tab="overview">
  <:tab name="overview">Overview</:tab>
  <:tab name="activity">Activity</:tab>
</.tabs_list>

<.tabs_list size="md" variant="ghost" active_tab="all">
  <:tab name="all">All</:tab>
  <:tab name="archived">Archived</:tab>
</.tabs_list>

Individual tab buttons stretch to the full height of the tablist (h-full), so the row stays visually consistent regardless of which tab is active.

Rich Tab Content

The :tab slot accepts arbitrary HEEx content, so tabs can include icons, badges, counts, or any other inline element. Add class="icon" to icon elements so the size variants pick them up automatically and shrink them to match the tab text:

<.tabs_list active_tab="messages">
  <:tab name="messages">
    <.icon name="hero-envelope" class="icon" /> Messages
    <.badge color="primary">3</.badge>
  </:tab>
  <:tab name="threads">
    <.icon name="hero-chat-bubble-left-right" class="icon" /> Threads
  </:tab>
  <:tab name="archived">
    <.icon name="hero-archive-box" class="icon" /> Archived
  </:tab>
</.tabs_list>

When a tab contains an element with class="icon", the size variants reduce the horizontal padding slightly and add a small gap between the icon and the label to keep the visual rhythm consistent.

Disabled Tabs

Mark a :tab as disabled by passing the standard HTML disabled attribute or aria-disabled="true". A disabled tab cannot be activated by clicking it, is skipped during arrow-key navigation, and is walked past when jumping with Home or End:

<.tabs_list active_tab="profile">
  <:tab name="profile">Profile</:tab>
  <:tab name="billing" disabled>Billing</:tab>
  <:tab name="team" aria-disabled="true">Team</:tab>
</.tabs_list>

Disabled tabs still occupy a slot in the visual row; they're just not interactive and not part of the keyboard cycle.

Selection and Focus

The tablist follows the WAI-ARIA tabs pattern with automatic activation, so moving to a tab also selects it and reveals its panel. Only the active tab is in the page's tab sequence: Tab moves focus into the tablist and lands on the active tab, then arrow keys move through the row. Right and Down go to the next enabled tab, Left and Up go to the previous one, and both wrap around the ends. Home jumps to the first enabled tab and End jumps to the last. Disabled tabs are skipped in every direction.

When the active tab changes, focus follows to the new tab and its panel is shown immediately. If the panel being hidden holds the focused element, focus is moved out of it first so it is not stranded inside a hidden subtree.

Keyboard activation runs the tab's phx-click exactly as a mouse click does, so a tablist that patches the URL or pushes an event stays in sync with the server when navigated with the keyboard.

Server-Driven Active Tab

The most common LiveView pattern is to keep the active tab in an assign and update it from a phx-click handler on each tab. The component reads active_tab on tabs_list and the matching active boolean on each tabs_panel, so deriving both from the same assign keeps them aligned across patches.

<.tabs id="account-tabs">
  <.tabs_list active_tab={@active_tab}>
    <:tab name="profile" phx-click={JS.push("set_tab", value: %{tab: "profile"})}>
      Profile
    </:tab>
    <:tab name="settings" phx-click={JS.push("set_tab", value: %{tab: "settings"})}>
      Settings
    </:tab>
  </.tabs_list>

  <.tabs_panel name="profile" active={@active_tab == "profile"}>
    Profile content...
  </.tabs_panel>
  <.tabs_panel name="settings" active={@active_tab == "settings"}>
    Settings content...
  </.tabs_panel>
</.tabs>
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

Local clicks vs server patches

On every click and keyboard activation the client updates the selected tab and panel visibility immediately, so the UI stays responsive even before a server round trip completes. After each LiveView patch it re-reads the server-rendered DOM and adopts whichever tab the server marked as active. As long as your assign is the source of truth, the local optimistic state and the patched state stay consistent.

To persist the active tab across full reloads or share a link to a specific panel, drive the assign from URL parameters instead:

<.tabs id="account-tabs">
  <.tabs_list active_tab={@active_tab}>
    <:tab name="profile" phx-click={JS.push("set_tab", value: %{tab: "profile"})}>
      Profile
    </:tab>
    <:tab name="settings" phx-click={JS.push("set_tab", value: %{tab: "settings"})}>
      Settings
    </:tab>
  </.tabs_list>

  <.tabs_panel name="profile" active={@active_tab == "profile"}>...</.tabs_panel>
  <.tabs_panel name="settings" active={@active_tab == "settings"}>...</.tabs_panel>
</.tabs>
def handle_params(params, _uri, socket) do
  {:noreply, assign(socket, :active_tab, params["tab"] || "profile")}
end

def handle_event("set_tab", %{"tab" => tab}, socket) do
  {:noreply, push_patch(socket, to: ~p"/account?tab=#{tab}")}
end

Dynamic Tabs

Tabs and panels can be generated from a collection. Pair :for on the tab slot with :for on the panels using the same source list, and key off a stable identifier:

<.tabs id="dynamic-tabs">
  <.tabs_list active_tab={@active_tab}>
    <:tab
      :for={tab <- @tabs}
      name={tab.id}
      phx-click={JS.push("set_tab", value: %{tab: tab.id})}
    >
      {tab.title}
    </:tab>
  </.tabs_list>

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

When the server adds, removes, or reorders tabs, the new buttons and panels are linked on the next patch, so dynamically added tabs become interactive without a remount.

Nested Tabs

Tabs can be nested by placing a second tabs/1 instance inside a tabs_panel. Each instance scopes its tablist and panel queries to its own container, so an inner tablist click never leaks up to the outer instance.

<.tabs id="parent">
  <.tabs_list variant="segmented" active_tab="profile">
    <:tab name="profile">Profile</:tab>
    <:tab name="settings">Settings</:tab>
  </.tabs_list>

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

  <.tabs_panel name="settings">...</.tabs_panel>
</.tabs>

Each tabs/1 needs its own unique id, since its client behavior is bound to that container.

Examples

A "view as" segmented switcher in a card header that flips a list view between groupings:

<section class="rounded-lg border border-base">
  <header class="flex items-center justify-between border-b border-base px-4 py-3">
    <h2 class="text-base font-semibold">Customers</h2>
    <.tabs id="customers-view">
      <.tabs_list size="xs" variant="segmented" active_tab={@view}>
        <:tab name="all" phx-click={JS.push("set_view", value: %{view: "all"})}>
          All
        </:tab>
        <:tab name="active" phx-click={JS.push("set_view", value: %{view: "active"})}>
          Active
        </:tab>
        <:tab name="churned" phx-click={JS.push("set_view", value: %{view: "churned"})}>
          Churned
        </:tab>
      </.tabs_list>

      <.tabs_panel name="all" active={@view == "all"}>
        <.live_component module={CustomersList} id="all" filter={:all} />
      </.tabs_panel>
      <.tabs_panel name="active" active={@view == "active"}>
        <.live_component module={CustomersList} id="active" filter={:active} />
      </.tabs_panel>
      <.tabs_panel name="churned" active={@view == "churned"}>
        <.live_component module={CustomersList} id="churned" filter={:churned} />
      </.tabs_panel>
    </.tabs>
  </header>
</section>

A settings page with URL-synced tabs and a disabled section gated behind a feature flag:

<.tabs id="settings">
  <.tabs_list active_tab={@active_tab}>
    <:tab name="profile" phx-click={JS.push("set_tab", value: %{tab: "profile"})}>
      <.icon name="hero-user" class="icon" /> Profile
    </:tab>
    <:tab name="security" phx-click={JS.push("set_tab", value: %{tab: "security"})}>
      <.icon name="hero-lock-closed" class="icon" /> Security
    </:tab>
    <:tab
      name="billing"
      disabled={!@billing_enabled}
      phx-click={JS.push("set_tab", value: %{tab: "billing"})}
    >
      <.icon name="hero-credit-card" class="icon" /> Billing
    </:tab>
  </.tabs_list>

  <.tabs_panel name="profile" active={@active_tab == "profile"} class="space-y-4 p-4">
    <.live_component module={ProfileForm} id="profile-form" user={@user} />
  </.tabs_panel>
  <.tabs_panel name="security" active={@active_tab == "security"} class="space-y-4 p-4">
    <.live_component module={SecurityForm} id="security-form" user={@user} />
  </.tabs_panel>
  <.tabs_panel name="billing" active={@active_tab == "billing"} class="space-y-4 p-4">
    <.live_component module={BillingForm} id="billing-form" user={@user} />
  </.tabs_panel>
</.tabs>

A documentation viewer with dynamic tabs generated from a list of source files:

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

  <.tabs_panel
    :for={doc <- @docs}
    name={doc.slug}
    active={@active_doc == doc.slug}
    class="prose max-w-none p-6"
  >
    {raw(doc.html)}
  </.tabs_panel>
</.tabs>

A workspace dashboard with nested tabs, where the outer tabs swap top-level sections and the inner tabs filter content inside one of the sections:

<.tabs id="workspace">
  <.tabs_list variant="default" active_tab={@section}>
    <:tab name="overview" phx-click={JS.push("set_section", value: %{section: "overview"})}>
      Overview
    </:tab>
    <:tab name="projects" phx-click={JS.push("set_section", value: %{section: "projects"})}>
      Projects
    </:tab>
  </.tabs_list>

  <.tabs_panel name="overview" active={@section == "overview"}>
    Overview content...
  </.tabs_panel>

  <.tabs_panel name="projects" active={@section == "projects"} class="space-y-4 p-4">
    <.tabs id="projects-filter">
      <.tabs_list size="xs" variant="ghost" active_tab={@project_filter}>
        <:tab name="all" phx-click={JS.push("filter_projects", value: %{filter: "all"})}>
          All
        </:tab>
        <:tab name="mine" phx-click={JS.push("filter_projects", value: %{filter: "mine"})}>
          Mine
        </:tab>
        <:tab name="starred" phx-click={JS.push("filter_projects", value: %{filter: "starred"})}>
          Starred
        </:tab>
      </.tabs_list>

      <.tabs_panel name="all" active={@project_filter == "all"}>...</.tabs_panel>
      <.tabs_panel name="mine" active={@project_filter == "mine"}>...</.tabs_panel>
      <.tabs_panel name="starred" active={@project_filter == "starred"}>...</.tabs_panel>
    </.tabs>
  </.tabs_panel>
</.tabs>

Summary

Components

Renders the tabs container that scopes a single tab instance and enables its client behavior.

Renders the tablist row that holds the interactive tab buttons.

Renders a content panel whose visibility is bound to the matching tab.

Components

tabs(assigns)

Renders the tabs container that scopes a single tab instance and enables its client behavior.

Use this component as the outermost wrapper around a tabs_list/1 and one or more tabs_panel/1 components. The container scopes the client's queries so a nested tabs/1 placed inside a panel does not leak into the outer instance: only direct-child tabs and direct-child panels are managed by this instance.

Each container needs a unique id because its client behavior is bound to this element by its id. When omitted, an id is generated automatically.

Attributes

  • id (:string) - Unique DOM id that the container's client behavior binds to. Defaults to a generated id when omitted. Provide an explicit id when nesting multiple tabs/1 instances or when you want to target the container from CSS or tests.

  • class (:any) - Additional CSS classes merged onto the wrapper <div>. Use this for layout concerns such as width, padding, or background. Variant and size styles live on tabs_list/1, not here.

    Defaults to nil.

  • Global attributes are accepted. Additional HTML attributes forwarded to the wrapper <div>. Useful for data-* hooks, test selectors, or phx-update overrides on the container.

Slots

  • inner_block (required) - The contents of the tabs container. Should include exactly one tabs_list/1 and one or more tabs_panel/1 components. Other markup is allowed, but only direct-child panels are bound to this tab instance.

tabs_list(assigns)

Renders the tablist row that holds the interactive tab buttons.

Use this component inside a tabs/1 container to render the strip of tab buttons. Each :tab slot becomes a button whose name attribute pairs it with a tabs_panel/1 of the same name. The variant attribute controls the visual treatment and the size attribute aligns the row's height with sibling buttons of the same size.

Server-rendered selection is driven by active_tab, which marks the matching tab as active and reveals its panel. The active tab carries a data-active attribute you can target for custom styling. When the active tab changes client-side via click or keyboard, the selection and the matching panel update without a server round trip.

Attributes

  • class (:any) - Additional CSS classes merged onto the tablist element. Stacked on top of the base tablist styles and the variant- and size-specific styles, so utilities passed here win. Use this to add custom spacing or to override the row layout.

    Defaults to nil.

  • active_tab (:string) - The name of the tab that should render as active on the server. The matching :tab slot renders selected, carries a data-active attribute for custom styling, and reveals its panel; the others render inactive. Defaults to the name of the first :tab slot when omitted. In LiveView, bind this to the same assign that drives the active boolean on each tabs_panel/1 so server and client state stay aligned.

  • variant (:string) - Visual treatment for the tablist and its buttons.

    • default: underlined tabs with a primary-colored bar under the active tab and a bottom border across the row. Use for page-level or section-level tabs.
    • segmented: a connected pill of buttons with a raised active tab inside a bordered container. Use for compact view switchers and segmented controls. Tabs stretch to fill the row evenly.
    • ghost: minimal styling with a soft accent background on the active tab. Use for in-card or in-panel tab strips that should carry little visual weight. Tabs stretch to fill the row evenly.

    Defaults to "default". Must be one of "default", "segmented", or "ghost".

  • size (:string) - Height of the tablist and the inner padding, font size, and icon size of each tab. Sizes are aligned with Fluxon.Components.Button of the same size, so a tab strip can sit flush next to buttons in a toolbar.

    • xs: 28px tall, xs text. Use for dense toolbars and table-cell controls.
    • sm: 32px tall, sm text. Use for compact panels and dialog headers.
    • md: 36px tall, sm text. Default size for page-level and card-level tabs.

    Defaults to "md". Must be one of "xs", "sm", or "md".

Slots

  • tab (required) - An interactive tab button. Required attributes:

    • name (string): pairs the tab with a tabs_panel/1 of the same name. Must be unique within the tablist.

    Any other attribute is forwarded to the underlying <button>, including phx-click, phx-value-*, disabled, aria-disabled, id, and arbitrary data-* attributes. Slot content becomes the button's label and may include icons, badges, or other inline elements. Add class="icon" to icon elements so the size variants resize them automatically.

  • inner_block (required) - Reserved for future use. Currently unused; tab buttons come from the :tab slot. Required by the component signature, so pass an empty body or omit the closing tag.

Basic Usage

<.tabs_list active_tab="overview">
  <:tab name="overview">Overview</:tab>
  <:tab name="activity">Activity</:tab>
</.tabs_list>

With Variant and Size

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

With Icons and Badges

<.tabs_list active_tab="messages">
  <:tab name="messages">
    <.icon name="hero-envelope" class="icon" /> Messages <.badge color="primary">3</.badge>
  </:tab>
  <:tab name="threads">
    <.icon name="hero-chat-bubble-left-right" class="icon" /> Threads
  </:tab>
</.tabs_list>

With a Disabled Tab

<.tabs_list active_tab="profile">
  <:tab name="profile">Profile</:tab>
  <:tab name="billing" disabled>Billing</:tab>
</.tabs_list>

tabs_panel(assigns)

Renders a content panel whose visibility is bound to the matching tab.

Use one tabs_panel/1 per :tab slot in the surrounding tabs_list/1. The name attribute must match the tab's name exactly; that pairing tells the component which panel to show when the tab is selected.

Inactive panels are hidden and pulled out of the layout, but their DOM (and any LiveComponent state inside) is preserved across switches, so moving between tabs never resets a panel's content. When a panel is hidden while it holds the focused element, focus is moved out of it first so it is not stranded inside a hidden subtree.

Attributes

  • name (:string) (required) - Identifier that pairs the panel with a :tab of the same name in the surrounding tabs_list/1. Must be unique within the tabs/1 container. The matching tab reveals this panel when it is selected.

  • class (:any) - Additional CSS classes merged onto the panel <div>. Use this for panel-level layout such as padding, grid columns, or backgrounds; the component itself ships no padding so panels sit flush to the tablist by default.

    Defaults to nil.

  • active (:boolean) - Controls server-rendered visibility. When true, the panel renders visible; otherwise it renders hidden and pulled out of the layout while its DOM stays mounted. In LiveView, derive this from the same assign that drives active_tab on tabs_list/1 (for example active={@active_tab == "profile"}) so the server-rendered state matches the active tab. Client-side activation flips visibility without a server round trip; the next patch reasserts whatever the server sends.

    Defaults to false.

  • Global attributes are accepted. Additional HTML attributes forwarded to the panel <div>. Useful for data-* hooks, custom ARIA properties, or phx-update overrides on the panel content.

Slots

  • inner_block (required) - Content rendered inside the panel. Visible when active is true (or when the matching tab is selected client-side); hidden otherwise.

Basic Usage

<.tabs_panel name="profile" active>
  Profile content here.
</.tabs_panel>

<.tabs_panel name="settings">
  Settings content here.
</.tabs_panel>

Server-Driven Active State

<.tabs_panel name="profile" active={@active_tab == "profile"}>
  <.live_component module={ProfileForm} id="profile-form" user={@user} />
</.tabs_panel>

With Custom Layout

<.tabs_panel name="settings" active class="grid grid-cols-2 gap-6 p-6">
  <.form for={@form} phx-submit="save">...</.form>
  <aside>...</aside>
</.tabs_panel>