PhiaUi.Components.Tabs (phia_ui v0.1.17)

Copy Markdown View Source

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

ComponentUse case
TabsContent panels on a single page — switching hides/shows content
TabsNavPage-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)}
end

Context 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

ElementRoleKey attributes
tabs_listtablist
tabs_triggertabaria-selected, aria-controls, id
tabs_contenttabpanelaria-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

tabs(assigns)

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 the value attrs of the tabs_trigger/1 / tabs_content/1 children. 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; :solid renders the shadcn segmented-control look with bg-muted track; :pill renders rounded-full pill triggers; :scrollable renders a horizontally scrollable underline tab bar for many tabs.

    Defaults to :underline. Must be one of :underline, :solid, :pill, or :scrollable.

  • stacked_mobile (:boolean) - When true, adds flex-col sm:flex-row to the tabs list container so that tab triggers stack vertically on mobile and return to horizontal layout on sm: 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 to nil.

  • Global attributes are accepted. HTML attributes forwarded to the wrapper div.

Slots

tabs_content(assigns)

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 by aria-controls="panel-{value}" on the trigger
  • aria-labelledby="trigger-{value}" — references the trigger button's id

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 corresponding tabs_trigger/1 value. The panel is visible when active == value and hidden (via hidden class) otherwise.

  • active (:string) - The currently active tab value. Set to the same assign as tabs/1's default_value. When using :let={ctx} on tabs/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 to nil.

  • Global attributes are accepted. HTML attributes forwarded to the panel div.

Slots

  • inner_block (required) - Panel content — rendered only when the tab is active.

tabs_list(assigns)

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 the variant on the parent tabs/1. Defaults to :underline. Must be one of :underline, :solid, :pill, or :scrollable.

  • stacked_mobile (:boolean) - When true, adds flex-col sm:flex-row to make tab triggers stack vertically on mobile and arrange horizontally on sm: screens and wider. Propagated automatically when using :let context from tabs/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

tabs_trigger(assigns)

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 readers
  • id="trigger-{value}" for aria-labelledby cross-reference
  • aria-selected="true|false" to announce current state
  • aria-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 the value of the corresponding tabs_content/1 panel and the default_value on tabs/1 when this tab should be active initially.

  • variant (:atom) - Visual style — must match the variant on the parent tabs/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 as tabs/1's default_value. When active == value, this trigger renders in its selected state. When using :let={ctx} on tabs/1, this is populated automatically.

    Defaults to nil.

  • disabled (:boolean) - When true, 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 to nil.

  • Global attributes are accepted. HTML attributes forwarded to the button. Use phx-click and phx-value-tab to 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.