Accessible content-switching Tabs component — fully server-rendered.
Tabs provides accessible panel switching with a tabs_list/1 trigger row
and tabs_content/1 panels. The active/inactive state is controlled by the
:default_value attr and resolved entirely at render time — no JavaScript
hook is required.
When to use Tabs vs TabsNav
| Component | Use case |
|---|---|
Tabs | Content panels on a single page — switching hides/shows content |
TabsNav | Page-level navigation — each tab links to a different route |
Server-driven vs client-driven switching
Static tabs (no server round-trip)
Pass a fixed default_value to show a specific tab on initial render.
For purely static content where tabs never change after mount:
<.tabs default_value="overview">
<.tabs_list>
<.tabs_trigger value="overview">Overview</.tabs_trigger>
<.tabs_trigger value="details">Details</.tabs_trigger>
</.tabs_list>
<.tabs_content value="overview"><p>Overview content</p></.tabs_content>
<.tabs_content value="details"><p>Details content</p></.tabs_content>
</.tabs>Server-driven switching (LiveView event)
Bind phx-click on triggers to update a LiveView assign, then pass it as
default_value. The server re-renders with the new active tab:
<%!-- In the LiveView render function --%>
<.tabs default_value={@active_tab}>
<.tabs_list>
<.tabs_trigger value="overview" phx-click="set_tab" phx-value-tab="overview">
Overview
</.tabs_trigger>
<.tabs_trigger value="analytics" phx-click="set_tab" phx-value-tab="analytics">
Analytics
</.tabs_trigger>
</.tabs_list>
<.tabs_content value="overview">
<%!-- Only rendered (not just hidden) when active --%>
<.overview_panel metrics={@metrics} />
</.tabs_content>
<.tabs_content value="analytics">
<.analytics_panel data={@analytics} />
</.tabs_content>
</.tabs>
<%!-- In the LiveView module --%>
def handle_event("set_tab", %{"tab" => tab}, socket) do
{:noreply, assign(socket, active_tab: tab)}
endContext propagation via :let
To avoid passing default_value / active manually to each trigger and
content panel, use :let to extract the context from tabs/1:
<.tabs :let={ctx} default_value="settings">
<.tabs_list>
<%!-- Spread ctx into each trigger — it provides active={...} --%>
<.tabs_trigger {ctx} value="settings">Settings</.tabs_trigger>
<.tabs_trigger {ctx} value="billing">Billing</.tabs_trigger>
</.tabs_list>
<.tabs_content {ctx} value="settings">
Settings panel
</.tabs_content>
<.tabs_content {ctx} value="billing">
Billing panel
</.tabs_content>
</.tabs>tabs/1 calls render_slot(@inner_block, %{active: @default_value}) and
sub-components receive this map via :let. Spreading {ctx} passes
active=default_value to the sub-component as an assign.
WAI-ARIA implementation
| Element | Role | Key attributes |
|---|---|---|
tabs_list | tablist | — |
tabs_trigger | tab | aria-selected, aria-controls, id |
tabs_content | tabpanel | aria-labelledby, id, hidden when inactive |
The tabs_trigger id is "trigger-{value}" and the tabs_content id
is "panel-{value}". The cross-references (aria-controls / aria-labelledby)
connect triggers to their panels in the accessibility tree.
Complete example — settings page
defmodule MyAppWeb.SettingsLive do
use MyAppWeb, :live_view
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
def render(assigns) do
~H"""
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-semibold mb-6">Settings</h1>
<.tabs default_value={@active_tab}>
<.tabs_list class="w-full justify-start">
<.tabs_trigger value="profile"
phx-click="set_tab" phx-value-tab="profile"
active={@active_tab}>
Profile
</.tabs_trigger>
<.tabs_trigger value="security"
phx-click="set_tab" phx-value-tab="security"
active={@active_tab}>
Security
</.tabs_trigger>
<.tabs_trigger value="notifications"
phx-click="set_tab" phx-value-tab="notifications"
active={@active_tab}>
Notifications
</.tabs_trigger>
</.tabs_list>
<.tabs_content value="profile" active={@active_tab} class="pt-4">
<.profile_form user={@current_user} />
</.tabs_content>
<.tabs_content value="security" active={@active_tab} class="pt-4">
<.security_settings />
</.tabs_content>
<.tabs_content value="notifications" active={@active_tab} class="pt-4">
<.notification_preferences />
</.tabs_content>
</.tabs>
</div>
"""
end
end
Summary
Functions
Renders the tabs root wrapper and exposes active-tab context via :let.
Renders a tab content panel (role="tabpanel").
Renders the tab trigger list container (role="tablist").
Renders a single tab trigger button.
Functions
Renders the tabs root wrapper and exposes active-tab context via :let.
render_slot/2 is called with %{active: @default_value} as the context
value. Sub-components receive this via :let and use the active key to
determine whether they are the currently selected tab.
Without :let (explicit active propagation)
<.tabs default_value={@tab}>
<.tabs_list>
<.tabs_trigger value="a" active={@tab}>A</.tabs_trigger>
<.tabs_trigger value="b" active={@tab}>B</.tabs_trigger>
</.tabs_list>
<.tabs_content value="a" active={@tab}>Panel A</.tabs_content>
<.tabs_content value="b" active={@tab}>Panel B</.tabs_content>
</.tabs>With :let (automatic context propagation)
<.tabs :let={ctx} default_value="a">
<.tabs_list>
<.tabs_trigger {ctx} value="a">A</.tabs_trigger>
<.tabs_trigger {ctx} value="b">B</.tabs_trigger>
</.tabs_list>
<.tabs_content {ctx} value="a">Panel A</.tabs_content>
<.tabs_content {ctx} value="b">Panel B</.tabs_content>
</.tabs>Attributes
default_value(:string) (required) - Value of the tab that should be active on render. Must exactly match one of thevalueattrs of thetabs_trigger/1/tabs_content/1children. Drive this from a LiveView assign for server-side tab switching:default_value={@active_tab}.variant(:atom) - Visual style of the tabs.:underline(default) renders a bottom-border indicator;:solidrenders the shadcn segmented-control look withbg-mutedtrack;:pillrenders rounded-full pill triggers;:scrollablerenders a horizontally scrollable underline tab bar for many tabs.Defaults to
:underline. Must be one of:underline,:solid,:pill, or:scrollable.stacked_mobile(:boolean) - Whentrue, addsflex-col sm:flex-rowto the tabs list container so that tab triggers stack vertically on mobile and return to horizontal layout onsm:screens and wider. Useful when there are many tabs that would overflow horizontally on small viewports.Defaults to
false.class(:string) - Additional CSS classes applied to the outer wrapper<div>. Defaults tonil.Global attributes are accepted. HTML attributes forwarded to the wrapper div.
Slots
inner_block(required) - Tab structure:tabs_list/1followed by one or moretabs_content/1panels. When using:let={ctx}, spread{ctx}on eachtabs_trigger/1andtabs_content/1to propagate the active tab value automatically.
Renders a tab content panel (role="tabpanel").
The panel is shown when active == value and hidden otherwise via Tailwind's
hidden class. Content is always rendered in the HTML (for SEO and ARIA
discoverability), but invisible when not active.
If you want to avoid rendering inactive content at all (e.g. for expensive queries), conditionally render panels in your LiveView template instead:
<%= if @active_tab == "analytics" do %>
<.tabs_content value="analytics" active={@active_tab}>
<.heavy_analytics_chart />
</.tabs_content>
<% end %>ARIA connections
id="panel-{value}"— referenced byaria-controls="panel-{value}"on the triggeraria-labelledby="trigger-{value}"— references the trigger button'sid
Example
<.tabs_content value="billing" active={@active_tab} class="pt-4">
<.billing_form subscription={@subscription} />
</.tabs_content>Attributes
value(:string) (required) - Unique string matching the correspondingtabs_trigger/1value. The panel is visible whenactive == valueand hidden (viahiddenclass) otherwise.active(:string) - The currently active tab value. Set to the same assign astabs/1'sdefault_value. When using:let={ctx}ontabs/1, this is populated automatically via the spread{ctx}.Defaults to
nil.class(:string) - Additional CSS classes applied to the panel div (e.g."pt-4"). Defaults tonil.Global attributes are accepted. HTML attributes forwarded to the panel div.
Slots
inner_block(required) - Panel content — rendered only when the tab is active.
Renders the tab trigger list container (role="tablist").
The list uses inline-flex to size itself to its content by default.
Override with class="w-full" for a full-width tab bar. The bg-muted
background and p-1 padding create the segmented-control look used by
shadcn/ui tabs.
Example
<.tabs_list>
<.tabs_trigger value="tab1">Tab 1</.tabs_trigger>
<.tabs_trigger value="tab2">Tab 2</.tabs_trigger>
</.tabs_list>
<%!-- Full-width, left-aligned tabs --%>
<.tabs_list class="w-full justify-start border-b bg-transparent rounded-none p-0">
...
</.tabs_list>Attributes
variant(:atom) - Visual style — must match thevarianton the parenttabs/1. Defaults to:underline. Must be one of:underline,:solid,:pill, or:scrollable.stacked_mobile(:boolean) - Whentrue, addsflex-col sm:flex-rowto make tab triggers stack vertically on mobile and arrange horizontally onsm:screens and wider. Propagated automatically when using:letcontext fromtabs/1.Defaults to
false.class(:string) - Additional CSS classes for the tab list container. Use"w-full"to stretch the list across its parent width, or"justify-start"to left-align tabs instead of center-aligning them.Defaults to
nil.Global attributes are accepted. HTML attributes forwarded to the tablist div.
Slots
inner_block(required) - One or moretabs_trigger/1components.
Renders a single tab trigger button.
The trigger compares active to value to determine its selected state:
- Active:
bg-background text-foreground shadow-sm— visually elevated - Inactive:
hover:bg-background/50— subtle hover, muted text
The button renders with:
role="tab"for screen readersid="trigger-{value}"foraria-labelledbycross-referencearia-selected="true|false"to announce current statearia-controls="panel-{value}"linking to the content panel
Example
<.tabs_trigger value="overview" active={@active_tab}
phx-click="set_tab" phx-value-tab="overview">
Overview
</.tabs_trigger>Attributes
value(:string) (required) - Unique string identifying this tab trigger. Must match thevalueof the correspondingtabs_content/1panel and thedefault_valueontabs/1when this tab should be active initially.variant(:atom) - Visual style — must match thevarianton the parenttabs/1. Defaults to:underline. Must be one of:underline,:solid,:pill, or:scrollable.active(:string) - The currently active tab value. Set to the same assign astabs/1'sdefault_value. Whenactive == value, this trigger renders in its selected state. When using:let={ctx}ontabs/1, this is populated automatically.Defaults to
nil.disabled(:boolean) - Whentrue, the trigger is non-interactive:pointer-events-none opacity-50. Useful for premium features not available on the current plan.Defaults to
false.class(:string) - Additional CSS classes applied to the trigger button. Defaults tonil.Global attributes are accepted. HTML attributes forwarded to the button. Use
phx-clickandphx-value-tabto drive server-side tab switching:<.tabs_trigger value="settings" phx-click="set_tab" phx-value-tab="settings"> Settings </.tabs_trigger>
Supports all globals plus: ["phx-click", "phx-value", "phx-value-tab"].
Slots
inner_block(required) - Trigger label — text, icon, or both.