Components

Navigation

PhoenixPaper.TopAppBar, NavigationRail, NavigationBar, Toolbar, Tabs, Breadcrumbs, Pagination, Menu, SearchBar and the List family.

Top App Bar

MD3 top app bars are surface-colored: the bar sits on surface and turns surface-container once content scrolls under it (a CSS scroll-driven animation; scrolled forces it). small and center_aligned are 64dp; medium and large are the Expressive flexible layouts, with the title below the icons, and subtitle adds a second line. Icon buttons inside need no color.

variant

small
center_aligned
medium
With a subtitle
large
With a subtitle
Option Type Default Description
color "surface" | "transparent" "surface"
paperize boolean true
position "static" | "relative" | "sticky" | "fixed" | "absolute" "static"
scrolled boolean false force the scrolled-under color
subtitle string nil a second line under the title (Expressive)
variant "small" | "center_aligned" | "medium" | "large" "small"
Slot Attrs Description
:actions trailing icon buttons
:inner_block

required

the title
:leading the navigation icon (menu/back)
Show code
<.pp_top_app_bar position="sticky">
  <:leading><.pp_navigation_rail_toggle for="app-rail" modal_only /></:leading>
  Inbox
  <:actions>
    <.pp_icon_button icon="hero-magnifying-glass" label="Search" />
    <.pp_icon_button icon="hero-ellipsis-vertical" label="More" />
  </:actions>
</.pp_top_app_bar>

<.pp_top_app_bar variant="large" subtitle="12 unread">
  <:leading><.pp_icon_button icon="hero-arrow-left" label="Back" /></:leading>
  Inbox
</.pp_top_app_bar>

Toolbar

An M3 Expressive toolbar, MD3's replacement for the bottom app bar: a row (or column) of actions for the current page. docked is a full-width 64dp bar; floating is a rounded pill sized to its items, optionally paired with a FAB. vibrant puts it on primary-container; give icon buttons color="inherit" there.

docked, and floating vibrant with a FAB

Option Type Default Description
color "standard" | "vibrant" "standard"
label string nil aria-label of the toolbar
orientation "horizontal" | "vertical" "horizontal"
paperize boolean true
position "static" | "relative" | "fixed" | "absolute" | "sticky" "static"
variant "docked" | "floating" "docked"
Slot Attrs Description
:fab a FAB paired with a floating toolbar
:inner_block

required

Show code
<.pp_toolbar position="fixed">
  <.pp_icon_button icon="hero-archive-box" label="Archive" />
  <.pp_icon_button icon="hero-trash" label="Delete" />
</.pp_toolbar>

<.pp_toolbar variant="floating" color="vibrant" position="fixed" class="bottom-4 inset-x-0 mx-auto">
  <.pp_icon_button icon="hero-bold" label="Bold" color="inherit" />
  <.pp_icon_button icon="hero-italic" label="Italic" color="inherit" />
  <:fab><.pp_fab icon="hero-check" label="Done" color="secondary-container" /></:fab>
</.pp_toolbar>

Tabs

Tabs, Tab and TabPanel switch client-side via Phoenix.LiveView.JS commands, no round trip. primary tabs have an indicator under the label and content; secondary tabs a thinner full-width one. layout="scrollable" scrolls a long row. They follow the ARIA keyboard model: only the selected tab is a Tab stop, and Left/Right (mirrored in RTL), Home and End move between tabs, skipping disabled ones. With the JS hook (Getting Started, step 4) the indicator slides between tabs.

primary, with icons and a badge

Upcoming flights.

secondary

Overview content.
Option Type Default Description
pp_tabs id string (required) shared with every Tab/TabPanel in the group
pp_tabs layout "fixed" | "scrollable" "fixed"
pp_tabs paperize boolean true
pp_tabs variant "primary" | "secondary" "primary"
pp_tab badge any nil true for a dot, or a count
pp_tab default_selected boolean false
pp_tab disabled boolean false
pp_tab id string (required) shared with the parent Tabs and matching TabPanel
pp_tab paperize boolean true
pp_tab ripple boolean true
pp_tab value string (required) unique within the group; matches a TabPanel
pp_tab_panel default_selected boolean false
pp_tab_panel id string (required) the same id passed to the parent pp_tabs/1
pp_tab_panel paperize boolean true
pp_tab_panel value string (required) matches the corresponding pp_tab/1's value
Slot Attrs Description
pp_tabs :inner_block

required

pp_tab :icon an optional icon — stacked above the label on primary tabs
pp_tab :inner_block

required

pp_tab_panel :inner_block

required

Show code
<.pp_tabs id="trip-tabs">
  <.pp_tab id="trip-tabs" value="flights" default_selected>
    <:icon><.pp_icon name="hero-paper-airplane" /></:icon>
    Flights
  </.pp_tab>
  <.pp_tab id="trip-tabs" value="trips" badge={2}>Trips</.pp_tab>
</.pp_tabs>
<.pp_tab_panel id="trip-tabs" value="flights" default_selected>Upcoming flights.</.pp_tab_panel>
<.pp_tab_panel id="trip-tabs" value="trips">Your trips.</.pp_tab_panel>

<.pp_tabs id="spec-tabs" variant="secondary" layout="scrollable">...</.pp_tabs>

Pagination

New

Page navigation from MD3 standard icon buttons: previous and next around the first and last page, the current page and one on each side, the rest collapsed into an ellipsis (so the control keeps its width as you page). The current page takes secondary-container and aria-current. Pages are 1-based; give path a function for links (patch by default), or on_change an event name.

Events (on_change): page 5 of 12

disabled

Option Type Default Description
count integer (required) the number of pages
disabled boolean false
link "patch" | "navigate" | "href" "patch" the kind of link path produces
on_change any nil an event name (or JS) for phx-click; the page is sent as phx-value-page
page integer (required) the current page, 1-based
paperize boolean true
path any nil a function from a page number to a URL; makes every page a link
target any nil phx-target for on_change
Show code
<%!-- Links: the page lives in the URL and handle_params/3 loads it --%>
<.pp_pagination page={@page} count={@total_pages} path={&~p"/users?page=#{&1}"} />

<%!-- Events: each button sends phx-value-page --%>
<.pp_pagination page={@page} count={@total_pages} on_change="paginate" />

# In the LiveView
def handle_event("paginate", %{"page" => page}, socket),
  do: {:noreply, assign(socket, :page, String.to_integer(page))}

List

MD3 list rows: one line (56dp), or two with :secondary (72dp), in body-large. Items render as links, buttons or plain rows depending on their attrs; the active item is secondary-container. :leading holds an icon or a pp_avatar, :trailing a count, icon or action. For a heading over a group, use a title-small pp_typography; to show and hide a group, render its items conditionally.

Option Type Default Description
pp_list paperize boolean true
pp_list_item active boolean false
pp_list_item disabled boolean false
pp_list_item href any nil
pp_list_item navigate any nil
pp_list_item paperize boolean true
pp_list_item patch any nil
pp_list_item ripple boolean true the Material ripple effect on click/tap — off whenever paperize is false, see PhoenixPaper.Ripple
Slot Attrs Description
pp_list :inner_block

required

pp_list_item :inner_block

required

the primary line of text
pp_list_item :leading an icon or avatar
pp_list_item :secondary a secondary line of text below the primary one
pp_list_item :trailing a trailing icon, badge, or action
Show code
<.pp_list>
  <.pp_typography variant="title-small" color="on-surface-variant" class="px-4 pt-2 pb-1">
    Main
  </.pp_typography>
  <.pp_list_item navigate={~p"/"} active>
    <:leading><.pp_icon name="hero-home" /></:leading>
    Home
    <:secondary>Overview</:secondary>
  </.pp_list_item>
  <.pp_list_item navigate={~p"/inbox"}>
    <:leading><.pp_icon name="hero-inbox" /></:leading>
    Inbox
    <:trailing><.pp_typography variant="label-medium">3</.pp_typography></:trailing>
  </.pp_list_item>
</.pp_list>

<%!-- pp_avatar in :leading: an image, falling back to initials --%>
<.pp_list>
  <.pp_list_item :for={user <- @users} navigate={~p"/users/#{user.id}"}>
    <:leading><.pp_avatar src={user.avatar_url}>{user.initials}</.pp_avatar></:leading>
    {user.name}
    <:secondary>{user.email}</:secondary>
  </.pp_list_item>
</.pp_list>

z7ealth 2026