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_panelEach :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.
| Variant | Treatment | Use Case |
|---|---|---|
default | Underlined 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. |
segmented | A 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. |
ghost | Button-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.
| Size | Height | Text | Use Case |
|---|---|---|---|
xs | 28px | xs | Dense toolbars, inline switchers, table-cell controls. |
sm | 32px | sm | Compact panels, dialog headers, sidebar widgets. |
md | 36px | sm | Default 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)}
endLocal 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}")}
endDynamic 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
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 multipletabs/1instances 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 ontabs_list/1, not here.Defaults to
nil.Global attributes are accepted. Additional HTML attributes forwarded to the wrapper
<div>. Useful fordata-*hooks, test selectors, orphx-updateoverrides on the container.
Slots
inner_block(required) - The contents of the tabs container. Should include exactly onetabs_list/1and one or moretabs_panel/1components. Other markup is allowed, but only direct-child panels are bound to this tab instance.
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) - Thenameof the tab that should render as active on the server. The matching:tabslot renders selected, carries adata-activeattribute for custom styling, and reveals its panel; the others render inactive. Defaults to thenameof the first:tabslot when omitted. In LiveView, bind this to the same assign that drives theactiveboolean on eachtabs_panel/1so 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 withFluxon.Components.Buttonof 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 atabs_panel/1of the same name. Must be unique within the tablist.
Any other attribute is forwarded to the underlying
<button>, includingphx-click,phx-value-*,disabled,aria-disabled,id, and arbitrarydata-*attributes. Slot content becomes the button's label and may include icons, badges, or other inline elements. Addclass="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:tabslot. 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>
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:tabof the samenamein the surroundingtabs_list/1. Must be unique within thetabs/1container. 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. Whentrue, 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 drivesactive_tabontabs_list/1(for exampleactive={@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 fordata-*hooks, custom ARIA properties, orphx-updateoverrides on the panel content.
Slots
inner_block(required) - Content rendered inside the panel. Visible whenactiveis 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>