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
| 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
secondary
| 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
NewPage 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))}
Search Bar
A 56dp rounded surface-container-high bar with a leading search icon (or your :leading), the input and a :trailing slot. Given :results, it opens into MD3's search view while it has focus: docked under the bar (view="docked", default), full screen ("fullscreen", with a back button), or full screen only below sm ("responsive"). Pure CSS (:focus-within), so render the results from your LiveView as the query changes. Type below.
Try it (docked search view)
| Option | Type | Default | Description |
|---|---|---|---|
back_label
|
string
|
"Back"
|
full-screen view's back button label |
full_width
|
boolean
|
false
|
|
id
|
string
|
nil
|
|
label
|
string
|
nil
|
aria-label; defaults to the placeholder |
name
|
string
|
"q"
|
|
paperize
|
boolean
|
true
|
|
placeholder
|
string
|
"Search"
|
|
results_open
|
boolean
|
false
|
keep the search view open |
value
|
string
|
nil
|
|
view
|
"docked" | "fullscreen" | "responsive"
|
"docked"
|
the search view while focused: docked under the bar, full screen, or full screen below sm |
| Slot | Attrs | Description |
|---|---|---|
:leading
|
|
replaces the search icon (a menu or back icon button) |
:results
|
|
the docked search view's content, shown while focused |
:trailing
|
|
trailing icon buttons or an avatar |
Show code
<form phx-change="search" phx-submit="search">
<.pp_search_bar name="q" value={@query} placeholder="Search folders" phx-debounce="150">
<:trailing><.pp_avatar>AL</.pp_avatar></:trailing>
<:results>
<.pp_list>
<.pp_list_item :for={hit <- @hits} navigate={hit.path}>{hit.title}</.pp_list_item>
</.pp_list>
</:results>
</.pp_search_bar>
</form>
# In the LiveView:
def handle_event("search", %{"q" => q}, socket),
do: {:noreply, assign(socket, query: q, hits: MyApp.Search.run(q))}
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.
Icons with a heading, and avatars with two lines
| 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>