Data Display
PhoenixPaper.Card, Avatar, Badge, Chip, Tooltip, Icon, Carousel, the Table family and TablePagination. The options tables on these pages are pp_tables too.
Card
MD3's three card styles: elevated (surface-container-low with a level-1 shadow), filled (surface-container-highest) and outlined (outline-variant border). Slots for :media (edge to edge on top), :title, :subhead and :actions. Give it href, navigate or patch and the whole card becomes clickable, with a state layer and focus ring; :actions stay outside the link.
variant
:media, and link mode (navigate)
| Option | Type | Default | Description |
|---|---|---|---|
href
|
any
|
nil
|
makes the media, title and body a link |
navigate
|
any
|
nil
|
like href, a LiveView live navigation |
paperize
|
boolean
|
true
|
|
patch
|
any
|
nil
|
like href, a LiveView live patch |
rel
|
string
|
nil
|
link mode: the link's rel, e.g. noopener |
ripple
|
boolean
|
true
|
the ripple on click in link mode — off whenever paperize is false |
target
|
string
|
nil
|
link mode: the link's target, e.g. _blank |
variant
|
"elevated" | "filled" | "outlined"
|
"elevated"
|
| Slot | Attrs | Description |
|---|---|---|
:actions
|
|
|
:inner_block
required |
|
|
:media
|
|
full-bleed media above the content (an image, a video) |
:subhead
|
|
|
:title
|
|
Show code
<.pp_card :for={variant <- ~w(elevated filled outlined)} variant={variant}>
<:title>{variant}</:title>
<:subhead>Card variant</:subhead>
Body text on the card's own surface.
</.pp_card>
<.pp_card>
<:media><img src="/images/1.jpg" alt="" class="h-36 w-full object-cover" /></:media>
<:title>Breakfast</:title>
<:subhead>Open until 11am</:subhead>
Pancakes, eggs and fresh coffee.
<:actions>
<.pp_button variant="text">Directions</.pp_button>
<.pp_button variant="tonal">Book</.pp_button>
</:actions>
</.pp_card>
<%!-- The whole card is clickable; :actions stay outside the link --%>
<.pp_card variant="outlined" navigate={~p"/components/surfaces"}>
<:title>Surfaces</:title>
Typography, Divider and the sheets.
</.pp_card>
Avatar
NewMD3's list leading element: a 40dp primary-container circle holding title-medium initials, an icon, or an image (src). If the image fails to load, the initials show instead. One MD3 size, shape and color; it's most at home in a list item's :leading slot.
Initials, icon, image
| Option | Type | Default | Description |
|---|---|---|---|
alt
|
string
|
""
|
for the <img>, when src is given |
paperize
|
boolean
|
true
|
|
src
|
string
|
nil
|
| Slot | Attrs | Description |
|---|---|---|
:inner_block
|
|
initials or an icon — the fallback shown with no src, or on image error |
Show code
<.pp_avatar>AL</.pp_avatar>
<.pp_avatar><.pp_icon name="hero-user" /></.pp_avatar>
<%!-- An image, falling back to the initials if it fails to load --%>
<.pp_avatar src={@user.avatar_url} alt={@user.name}>GH</.pp_avatar>
Badge
MD3's two badges, anchored to the corner of their child, always in the error color. Without content it's the small 6dp dot; with content it's the large badge holding a count or short label. Counts above max (999 by default) show as 999+, and a count of 0 hides the badge. invisible hides it without removing it.
Small (no content) and large (content)
max and zero
| Option | Type | Default | Description |
|---|---|---|---|
content
|
any
|
nil
|
a count or short label — nil renders the small (dot) badge |
invisible
|
boolean
|
false
|
hide the badge |
max
|
integer
|
999
|
caps a numeric content at max+ |
paperize
|
boolean
|
true
|
| Slot | Attrs | Description |
|---|---|---|
:inner_block
required |
|
the element the badge sits on |
Show code
<%!-- small badge: no content --%>
<.pp_badge><.pp_icon name="hero-chat-bubble-left" /></.pp_badge>
<%!-- large badge: a count or short label --%>
<.pp_badge content={4}><.pp_icon name="hero-envelope" /></.pp_badge>
<.pp_badge content={150} max={99}><.pp_icon name="hero-inbox" /></.pp_badge>
<%!-- a count of 0 hides it --%>
<.pp_badge content={@unread}><.pp_icon name="hero-bell" /></.pp_badge>
Chip
MD3's four chips: assist (a smart action with a leading icon), filter (toggles; a check appears when selected), input (a user-entered value, removable) and suggestion (a generated reply or query). 32dp, 8dp corners, outlined; elevated swaps the outline for a filled shadowed chip. Filter chips toggle like buttons: selected + phx-click, or toggle on the client.
assist and suggestion
filter (click to toggle)
input (✕ removes)
| Option | Type | Default | Description |
|---|---|---|---|
clickable
|
boolean
|
false
|
input chips: render a <button> instead of a static <div> |
deletable
|
boolean
|
false
|
trailing remove control (input chips) |
delete_label
|
string
|
"Remove"
|
|
disabled
|
boolean
|
false
|
|
elevated
|
boolean
|
false
|
fill + shadow instead of the outline |
group
|
string
|
nil
|
filter chips: single-select group name |
on_delete
|
JS
|
%JS{}
|
JS run by the remove control, e.g. JS.push("remove_chip") |
on_toggle
|
JS
|
%JS{}
|
filter chips: JS run after a client toggle |
paperize
|
boolean
|
true
|
|
ripple
|
boolean
|
true
|
|
selected
|
boolean
|
nil
|
filter/input chips: the (initial) selected state |
toggle
|
boolean
|
false
|
filter chips: toggle on the client |
type
|
"button" | "submit" | "reset"
|
"button"
|
|
variant
|
"assist" | "filter" | "input" | "suggestion"
|
"assist"
|
| Slot | Attrs | Description |
|---|---|---|
:icon
|
|
a leading icon or avatar |
:inner_block
required |
|
the chip's label |
Show code
<.pp_chip variant="assist" phx-click="add_to_calendar">
<:icon><.pp_icon name="hero-calendar" size="sm" /></:icon>
Add to calendar
</.pp_chip>
<%!-- filter: a client-side toggle (or selected={@unread} + phx-click) --%>
<.pp_chip variant="filter" toggle selected={false}>Unread</.pp_chip>
<%!-- input: removable --%>
<.pp_chip variant="input" deletable on_delete={JS.push("remove_tag", value: %{tag: "elixir"})}>
elixir
</.pp_chip>
<.pp_chip variant="suggestion" phx-click="reply" phx-value-text="Sounds good">Sounds good</.pp_chip>
Tooltip
A label shown on hover/focus. plain is MD3's small inverse-surface tooltip for a short title; rich is a surface-container card with a subhead, supporting text and optional :actions, for more context. Pure CSS (group-hover/group-focus-within), no collision detection.
plain (hover each)
rich
| Option | Type | Default | Description |
|---|---|---|---|
paperize
|
boolean
|
true
|
|
placement
|
"top" | "bottom" | "left" | "right"
|
"top"
|
|
subhead
|
string
|
nil
|
rich tooltips: a title above the text |
title
|
any
|
nil
|
the tooltip text (rich: supporting text) — nil or "" disables it |
variant
|
"plain" | "rich"
|
"plain"
|
| Slot | Attrs | Description |
|---|---|---|
:actions
|
|
rich tooltips: text buttons under the text |
:inner_block
required |
|
the trigger element |
Show code
<.pp_tooltip title="Delete">
<.pp_icon_button icon="hero-trash" label="Delete" title={false} />
</.pp_tooltip>
<.pp_tooltip title="Bottom" placement="bottom">
<.pp_button variant="outlined">Bottom</.pp_button>
</.pp_tooltip>
<.pp_tooltip variant="rich" subhead="Rich tooltip" title="Supporting text with more context.">
<.pp_button variant="tonal">Hover me</.pp_button>
<:actions><.pp_button variant="text" size="xs">Learn more</.pp_button></:actions>
</.pp_tooltip>
Icon
Renders the app's own heroicon classes: no bundled icon set, no extra dependency. Size it with size (MD3's 24dp is md, the default), not class: a plain class="size-4" would lose to the built-in size.
Colors via text color
size
| Option | Type | Default | Description |
|---|---|---|---|
name
|
string (required)
|
|
a heroicon class, e.g. "hero-check" |
paperize
|
boolean
|
true
|
|
size
|
"xs" | "sm" | "md" | "lg" | "xl" | "none"
|
"md"
|
xs=16dp, sm=20dp, md=24dp, lg=32dp, xl=40dp; none emits no size class |
Show code
<.pp_icon name="hero-check" class="text-pp-tertiary" />
<%!-- size, not class="size-4" (which loses to the built-in size) --%>
<.pp_icon :for={size <- ~w(xs sm md lg xl)} name="hero-star" size={size} />
Carousel
An M3 Expressive carousel: a horizontally scrolling row of visual items with snap points. multi_browse items narrow as they reach the edges and open up as they scroll in (a CSS scroll-timeline mask); hero shows one large item plus a peek; uncontained is plain fixed-size items; full_screen is one item per screen. controls adds previous/next buttons for mice.
multi_browse, with controls
hero
| Option | Type | Default | Description |
|---|---|---|---|
controls
|
boolean
|
false
|
|
height
|
"sm" | "md" | "lg"
|
"md"
|
|
id
|
string
|
nil
|
|
item_size
|
"sm" | "md" | "lg"
|
"md"
|
item width (multi_browse, uncontained) |
label
|
string
|
nil
|
aria-label of the carousel region |
layout
|
"multi_browse" | "hero" | "uncontained" | "full_screen"
|
"multi_browse"
|
|
paperize
|
boolean
|
true
|
| Slot | Attrs | Description |
|---|---|---|
:item
required |
label, href, navigate, patch, on_click
|
Show code
<.pp_carousel id="featured" label="Featured places" controls>
<:item :for={place <- @places} label={place.name} navigate={~p"/places/#{place.id}"}>
<img src={place.photo} alt="" class="size-full object-cover" />
</:item>
</.pp_carousel>
<.pp_carousel id="hero" layout="hero" height="sm" label="Featured">
<:item :for={place <- @places} label={place.name}>
<img src={place.photo} alt="" class="size-full object-cover" />
</:item>
</.pp_carousel>
Table
NewA data table from MD3 parts: title-small headers over body-medium rows, outline-variant dividers, the state layer on row hover, and secondary-container for selected rows. A sortable header cell renders an icon arrow with aria-sort. TableContainer is the card surface around it (variant as for Card, outlined by default); sticky_header pins the header while the container scrolls.
Sortable header, a selected row, a footer
| Calories | Protein (g) | |
|---|---|---|
| Cupcake | 305 | 4.3 |
| Eclair | 262 | 6.0 |
| Frozen yoghurt | 159 | 4.0 |
| Gingerbread | 356 | 3.9 |
| Ice cream sandwich | 237 | 4.3 |
| Total | 1319 |
| Option | Type | Default | Description |
|---|---|---|---|
pp_table_container paperize
|
boolean
|
true
|
|
pp_table_container variant
|
"elevated" | "filled" | "outlined"
|
"outlined"
|
the surface, as for PhoenixPaper.Card |
pp_table paperize
|
boolean
|
true
|
|
pp_table sticky_header
|
boolean
|
false
|
pins TableHead to the top of the nearest scrolling ancestor, e.g. TableContainer |
pp_table_head paperize
|
boolean
|
true
|
|
pp_table_body paperize
|
boolean
|
true
|
|
pp_table_row paperize
|
boolean
|
true
|
|
pp_table_row selected
|
boolean
|
false
|
a stronger, persistent highlight |
pp_table_cell align
|
"left" | "center" | "right"
|
"left"
|
|
pp_table_cell paperize
|
boolean
|
true
|
|
pp_table_cell sort_direction
|
string
|
nil
|
nil (sortable but inactive) | "asc" | "desc" — only meaningful with sortable |
pp_table_cell sortable
|
boolean
|
false
|
renders a clickable sort-direction arrow — only meaningful with variant="head" |
pp_table_cell variant
|
"head" | "body"
|
"body"
|
|
pp_table_footer paperize
|
boolean
|
true
|
| Slot | Attrs | Description |
|---|---|---|
pp_table_container :inner_block
required |
|
|
pp_table :inner_block
required |
|
|
pp_table_head :inner_block
required |
|
|
pp_table_body :inner_block
required |
|
|
pp_table_row :inner_block
required |
|
|
pp_table_cell :inner_block
required |
|
|
pp_table_footer :inner_block
required |
|
Show code
<.pp_table_container>
<.pp_table>
<.pp_table_head>
<.pp_table_row>
<.pp_table_cell variant="head" sortable sort_direction={@sort} phx-click="sort">
Dessert
</.pp_table_cell>
<.pp_table_cell variant="head" align="right">Calories</.pp_table_cell>
</.pp_table_row>
</.pp_table_head>
<.pp_table_body>
<.pp_table_row :for={d <- @desserts} selected={d.id == @selected_id}>
<.pp_table_cell>{d.name}</.pp_table_cell>
<.pp_table_cell align="right">{d.calories}</.pp_table_cell>
</.pp_table_row>
</.pp_table_body>
</.pp_table>
</.pp_table_container>
Table Pagination
NewA table footer bar from MD3 parts: a rows-per-page picker (a pp_menu), the range (1–5 of 47) and previous/next icon buttons. count is the total number of rows and page is 1-based. Give path a (page, rows_per_page) function for links, or on_page_change and on_rows_per_page_change event names; picking a new page size goes back to page 1.
47 rows, events
| Name | |
|---|---|
| User 1 | user1@example.com |
| User 2 | user2@example.com |
| User 3 | user3@example.com |
| User 4 | user4@example.com |
| User 5 | user5@example.com |
| Option | Type | Default | Description |
|---|---|---|---|
count
|
integer (required)
|
|
the total number of rows |
id
|
string (required)
|
|
the root element's id; the rows-per-page menu derives its own from it |
link
|
"patch" | "navigate" | "href"
|
"patch"
|
|
on_page_change
|
any
|
nil
|
event for prev/next; sends phx-value-page |
on_rows_per_page_change
|
any
|
nil
|
event for the picker; sends phx-value-rows_per_page |
page
|
integer (required)
|
|
the current page, 1-based |
paperize
|
boolean
|
true
|
|
path
|
any
|
nil
|
a function (page, rows_per_page) -> URL; makes every control a link |
range_label
|
any
|
nil
|
a function %{from, to, count} -> text; default "1–10 of 47" |
rows_per_page
|
integer (required)
|
|
|
rows_per_page_label
|
string
|
"Rows per page:"
|
|
rows_per_page_options
|
list
|
[10, 25, 50, 100]
|
the page sizes offered; [] hides the picker |
target
|
any
|
nil
|
phx-target for the events |
Show code
<.pp_table_container>
<.pp_table>...</.pp_table>
<%!-- Links: (page, rows_per_page) -> URL --%>
<.pp_table_pagination
id="users-pagination"
page={@page}
count={@total_rows}
rows_per_page={@per_page}
path={&~p"/users?page=#{&1}&per_page=#{&2}"}
/>
</.pp_table_container>
<%!-- Or events: phx-value-page / phx-value-rows_per_page --%>
<.pp_table_pagination
id="users-pagination"
page={@page}
count={@total_rows}
rows_per_page={@per_page}
on_page_change="page"
on_rows_per_page_change="per_page"
/>