Fluxon.Components.Navlist (Fluxon v3.0.0)

A compound navigation system for building structured menus, sidebars, and section indexes.

Navlist is a pure-markup component family with no client-side state. It composes a <nav> container, optional section headings, and link items that delegate to Phoenix.Component.link/1 for navigation. Items render an active state via the data-active attribute, which makes the current selection styleable from CSS without conditional class logic on the server.

The system is composed of a few pieces meant to compose together:

  • navlist/1: the container that provides spacing, an optional heading, and stacking of items.
  • navheading/1: an <h3> label used to group related items inside or above a list.
  • navlink/1: an interactive item that renders as a link or button depending on which attributes are passed through.

A typical structure looks like this:

navlist
 navheading (optional, rendered automatically when `heading` is set)
 navlink
 navlink
 navlink

Multiple navlists stacked one after another receive automatic top spacing through a CSS sibling selector ([&+[data-part=navlist]]:mt-6), so adjacent sections separate visually without extra wrappers.

Choosing between navlist and tabs

Use navlist for navigation that changes the page or route (sidebars, settings indexes, docs menus). Use Fluxon.Components.Tabs for in-page panel switching where content swaps inside the same view without a navigation event.

Usage

A simple navigation list with a heading and three items:

<.navlist heading="Main Navigation">
  <.navlink navigate={~p"/dashboard"} active>
    <.icon name="hero-home" class="size-5" /> Dashboard
  </.navlink>
  <.navlink navigate={~p"/projects"}>
    <.icon name="hero-folder" class="size-5" /> Projects
  </.navlink>
  <.navlink navigate={~p"/settings"}>
    <.icon name="hero-cog-6-tooth" class="size-5" /> Settings
  </.navlink>
</.navlist>

The heading attribute is a shortcut for rendering a navheading/1 as the first child. For fully custom heading markup, omit heading and place a navheading/1 (or any element) inside the slot directly.

Sections

Stack multiple navlists to group items into labeled sections. Adjacent navlists are separated automatically with top margin:

<.navlist heading="Workspace">
  <.navlink navigate={~p"/dashboard"} active>
    <.icon name="hero-home" class="size-5" /> Dashboard
  </.navlink>
  <.navlink navigate={~p"/projects"}>
    <.icon name="hero-folder" class="size-5" /> Projects
  </.navlink>
</.navlist>

<.navlist heading="Account">
  <.navlink navigate={~p"/profile"}>
    <.icon name="hero-user" class="size-5" /> Profile
  </.navlink>
  <.navlink navigate={~p"/preferences"}>
    <.icon name="hero-cog-6-tooth" class="size-5" /> Preferences
  </.navlink>
</.navlist>

Active State

Mark the current item with the active attribute. Active styling is applied through the data-active attribute on the rendered element, so it can be combined or extended with any data-active: Tailwind variant on a custom class:

<.navlink navigate={~p"/dashboard"} active={@current_path == "/dashboard"}>
  Dashboard
</.navlink>

Computing active from a LiveView assign keeps the source of truth on the server. Because the active state is expressed as a data attribute, no JavaScript is required to keep styles in sync.

Navigation Modes

navlink/1 forwards every link-related attribute to Phoenix.Component.link/1, so the same item can perform different kinds of navigation depending on which attribute is set:

AttributeBehavior
navigateFull LiveView navigation to a new live session. Use for top-level destinations.
patchPatch the current LiveView with a new URL. Use for tabs and filters that share state.
hrefStandard browser navigation. Use for external links or non-LiveView routes.
phx-clickClient or server event without navigation. Use for toggles and expandable groups.

Standard anchor attributes (target, download, rel, hreflang, type, referrerpolicy), link options (replace, method, csrf_token), and button attributes (disabled, formaction, name, value, etc.) are all accepted via the global :rest attribute.

<.navlink patch={~p"/messages?folder=inbox"} replace>Inbox</.navlink>
<.navlink navigate={~p"/billing"}>Billing</.navlink>
<.navlink href="https://docs.example.com" target="_blank" rel="noopener">
  Documentation
</.navlink>

Icons and Trailing Content

The link slot is a flex row, so icons and trailing badges align horizontally with consistent spacing. Use ml-auto on the trailing element to push it to the right edge:

<.navlist heading="Inbox">
  <.navlink href="/inbox/unread">
    <.icon name="hero-inbox" class="size-5" />
    Unread
    <.badge variant="pill" color="red" class="ml-auto">23</.badge>
  </.navlink>

  <.navlink href="/inbox/starred">
    <.icon name="hero-star" class="size-5" />
    Starred
    <.badge variant="pill" class="ml-auto">5</.badge>
  </.navlink>

  <.navlink href="/inbox/archive">
    <.icon name="hero-archive-box" class="size-5" />
    Archive
  </.navlink>
</.navlist>

Icons rendered with the icon helper inherit a default size-4 through the link's internal styles when they carry the .icon class, so you can omit an explicit size in simple cases and pass a class (for example class="size-5") only when you want a larger icon.

Custom Headings

When the built-in heading attribute is not enough, render navheading/1 directly inside the list. This is useful for uppercase labels, captions, or headings that need badges and icons:

<.navlist>
  <.navheading class="text-xs uppercase tracking-wider text-foreground-softest">
    Customers
  </.navheading>

  <.navlink navigate={~p"/customers"}>Customers</.navlink>
  <.navlink navigate={~p"/segments"}>Segments</.navlink>
</.navlist>

Expandable Groups

Navlist has no built-in expand/collapse behavior. Hierarchical groups are composed by combining navlink/1 with Phoenix.LiveView.JS.toggle_attribute/1 on a shared data-expanded attribute, then animating a sibling container with Tailwind grid utilities:

<.navlist heading="Sales">
  <.navlink phx-click={JS.toggle_attribute({"data-expanded", ""})}>
    <.icon name="hero-users" class="size-5" /> Customers
    <.icon
      name="hero-chevron-right"
      class="size-3 ml-auto text-zinc-500 in-data-expanded:rotate-90 transition-transform duration-200"
    />
  </.navlink>
  <div class="grid grid-rows-[0fr] [[data-expanded]+&]:grid-rows-[1fr] transition-all duration-200">
    <div class="overflow-hidden px-4 border-l ml-3">
      <.navlist>
        <.navlink navigate={~p"/customers/active"}>Active</.navlink>
        <.navlink navigate={~p"/customers/at-risk"}>At Risk</.navlink>
      </.navlist>
    </div>
  </div>

  <.navlink navigate={~p"/subscriptions"}>
    <.icon name="hero-arrow-path" class="size-5" /> Subscriptions
  </.navlink>
</.navlist>

The pattern relies on three pieces:

  • JS.toggle_attribute({"data-expanded", ""}) toggles a marker attribute on the trigger.
  • The collapsible panel uses grid-rows-[0fr] collapsed and grid-rows-[1fr] when expanded, which animates height smoothly through a CSS transition.
  • The chevron rotates via the in-data-expanded:rotate-90 Tailwind variant, reading the expansion state from the trigger ancestor.

Because the toggle is a client-side attribute change, expand/collapse state is preserved across LiveView patches without round-tripping to the server.

Persisting expanded state

Client-side toggles are reset on full page navigation. To remember which groups are open across navigations, drive the data-expanded attribute from a server assign instead of toggling it on the client.

Examples

Sidebar with sections, icons, and a counter:

<aside class="w-64 p-4">
  <.navlist heading="Workspace">
    <.navlink navigate={~p"/dashboard"} active={@section == :dashboard}>
      <.icon name="hero-home" class="size-5" /> Dashboard
    </.navlink>
    <.navlink navigate={~p"/inbox"} active={@section == :inbox}>
      <.icon name="hero-inbox" class="size-5" /> Inbox
      <.badge variant="pill" color="red" class="ml-auto">{@unread_count}</.badge>
    </.navlink>
    <.navlink navigate={~p"/projects"} active={@section == :projects}>
      <.icon name="hero-folder" class="size-5" /> Projects
    </.navlink>
  </.navlist>

  <.navlist heading="Account">
    <.navlink navigate={~p"/profile"}>
      <.icon name="hero-user" class="size-5" /> Profile
    </.navlink>
    <.navlink navigate={~p"/billing"}>
      <.icon name="hero-credit-card" class="size-5" /> Billing
    </.navlink>
  </.navlist>
</aside>

Settings index that patches the current LiveView:

<.navlist heading="Settings">
  <.navlink :for={tab <- @tabs} patch={~p"/settings?tab=#{tab.id}"} active={@active == tab.id}>
    <.icon name={tab.icon} class="size-5" /> {tab.label}
  </.navlink>
</.navlist>

Action item that triggers a server event with a confirmation prompt:

<.navlist heading="Danger Zone">
  <.navlink phx-click="archive_workspace" data-confirm="Archive this workspace?">
    <.icon name="hero-archive-box" class="size-5" /> Archive workspace
  </.navlink>
  <.navlink
    phx-click="delete_workspace"
    data-confirm="This cannot be undone. Continue?"
    class="text-danger data-active:bg-danger/10"
  >
    <.icon name="hero-trash" class="size-5" /> Delete workspace
  </.navlink>
</.navlist>

Rendering items from a list with :for:

<.navlist heading="Customers">
  <.navlink
    :for={item <- @nav_items}
    navigate={item.path}
    active={item.path == @current_path}
  >
    <.icon name={item.icon} class="size-5" />
    <span class="grow">{item.label}</span>
    <.badge :if={item.badge} color="blue">{item.badge}</.badge>
  </.navlink>
</.navlist>

Custom-styled navlinks for a marketing-style sidebar:

<.navlist>
  <.navheading class="text-xs uppercase font-medium text-zinc-400">
    Customers
  </.navheading>

  <.navlink
    :for={
      {icon, label, badge, path, active} <- [
        {"hero-users", "Customers", nil, ~p"/customers", false},
        {"hero-shopping-bag", "Subscriptions", "23", ~p"/subscriptions", true},
        {"hero-cube", "Products", nil, ~p"/products", false},
        {"hero-tag", "Coupons", nil, ~p"/coupons", false}
      ]
    }
    navigate={path}
    active={active}
    class={[
      "group py-1.5 relative",
      "hover:text-blue-600 hover:bg-white hover:shadow-sm",
      "hover:ring-1 ring-zinc-200",
      "hover:after:absolute hover:after:inset-y-0 hover:after:left-0",
      "hover:after:my-1.5 hover:after:w-1 hover:after:bg-blue-600",
      "hover:after:rounded-r-md"
    ]}
  >
    <.icon class="size-5 text-zinc-500 group-hover:text-blue-600" name={icon} />
    <span class="grow">{label}</span>
    <.badge :if={badge} color="blue">{badge}</.badge>
  </.navlink>
</.navlist>

Summary

Components

Renders an <h3> heading styled to label a group of navigation items.

Renders an interactive navigation item that delegates to Phoenix.Component.link/1.

Renders a <nav> container that stacks navigation items with consistent spacing.

Components