Components

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

elevated
Card variant
Body text on the card's own surface.
filled
Card variant
Body text on the card's own surface.
outlined
Card variant
Body text on the card's own surface.

:media, and link mode (navigate)

Breakfast
Open until 11am
Pancakes, eggs and fresh coffee.
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

New

MD3'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

AL GH Grace Hopper AT Alan Turing
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)

4 New

max and zero

999+ 99+
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)

React
Elixir
Phoenix
LiveView
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} />

Table

New

A 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

New

A 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 Email
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
Rows per page:
1–5 of 47
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"
/>

z7ealth 2026