PhoenixPaper
A Material Design 3 component library for Phoenix — including M3 Expressive (springs, shape morphing, the new button sizes, button groups, FAB menu, loading indicator, toolbars, navigation rail) — styled with Tailwind CSS.
MD3 is the only source: every component is one the MD3 spec defines, built to that spec, adapted to Phoenix's server-rendered, stateless-function-component model. Things MD3 doesn't define (layout grids, tables, pagination, breadcrumbs, ...) are left to your own Tailwind, using the same MD3 tokens.
See AGENTS.md for the framework's ground rules: the
paperize escape hatch every component supports, the MD3 token layer
(color roles, type scale, shape, elevation, state layers, motion), and the
icon strategy (reusing the heroicons every mix phx.new
app already vendors, no extra dependency).
Status
Warning
PhoenixPaper is in active development. Bugs are expected, and component
APIs may change between 0.x releases (breaking changes are always
called out in the CHANGELOG). The goal is a stable,
semver-guaranteed API at 1.0.0. Until then, pin a minor version
(e.g. ~> 0.5.0) and please
report issues you run into.
Installation
Add phoenix_paper to your mix.exs deps:
def deps do
[
{:phoenix_paper, "~> 0.5.0"}
]
end
Then, in lib/my_app_web.ex, import the components next to your existing
core_components:
defp html_helpers do
quote do
use PhoenixPaper.Components
# ...
end
end
And wire up the Tailwind theme in assets/css/app.css:
@import "tailwindcss";
@import "../../deps/phoenix_paper/priv/static/phoenix_paper.css";
The stylesheet carries its own @source for PhoenixPaper's lib/, so there's no separate @source line to add.
Register the PhoenixPaper LiveView hook in assets/js/app.js (Phoenix's
esbuild resolves deps/ packages by name):
import PhoenixPaperHooks from "phoenix_paper"
const liveSocket = new LiveSocket("/live", Socket, {hooks: {...PhoenixPaperHooks}, ...})
It adds what CSS can't do everywhere: the sliding tab indicator, drag-to-dismiss bottom sheets, time-picker dial dragging, menus and tooltips flipping at the viewport edge, the scrolled top app bar and carousel masking in Firefox, and the loading indicator's morph in Safari. Components still render and work before LiveView connects and on controller-rendered pages, just without those behaviors.
MD3's typeface is Roboto Flex. PhoenixPaper doesn't load fonts; add it to
your root layout (or override --font-pp-brand/--font-pp-plain):
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,400;8..144,500;8..144,700&display=swap">
Theming
One scheme ships: the MD3 baseline (seed #6750A4), light and dark
(data-theme="dark", or the OS preference when data-theme isn't set).
For your brand, generate a full MD3 scheme from one seed color:
mix phoenix_paper.gen.theme --seed "#0b57d0"
It writes assets/css/phoenix_paper_theme.css (every color role, light
and dark) using Material's HCT color science; import it after
phoenix_paper.css. --scheme picks the variant (tonal_spot, the
default, neutral, vibrant, expressive, fidelity, monochrome) and
--secondary/--tertiary/--neutral/--error pin core colors. A scheme
exported from
Material Theme Builder
works too: paste its roles as --color-pp-* overrides.
Usage
<div class="flex">
<%!-- Expressive navigation rail: modal on phones, collapsed/expandable
from md up. It replaces 0.3's Drawer. --%>
<.pp_navigation_rail id="app-rail">
<:fab icon="hero-pencil" label="Compose" navigate={~p"/compose"} />
<.pp_navigation_rail_item icon="hero-inbox" active_icon="hero-inbox-solid" label="Inbox" navigate={~p"/"} active badge={4} />
<.pp_navigation_rail_item icon="hero-paper-airplane" label="Sent" navigate={~p"/sent"} />
</.pp_navigation_rail>
<main class="flex-1">
<.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_theme_toggle />
</:actions>
</.pp_top_app_bar>
...
</main>
</div>
<%!-- Buttons: filled / tonal / elevated / outlined / text, Expressive sizes
xs..xl, round or square, shape morphing on press --%>
<.pp_button>Save</.pp_button>
<.pp_button variant="tonal" size="md" shape="square">
<:start_icon><.pp_icon name="hero-plus" /></:start_icon>
New
</.pp_button>
<.pp_button href={~p"/issues"} variant="text">Issues</.pp_button>
<%!-- Toggle buttons and connected button groups, client-side, no handler --%>
<.pp_button_group variant="connected" aria-label="View">
<.pp_button variant="tonal" group="view" selected>Day</.pp_button>
<.pp_button variant="tonal" group="view" selected={false}>Week</.pp_button>
</.pp_button_group>
<.pp_icon_button icon="hero-star" selected_icon="hero-star-solid" label="Star" variant="tonal" toggle selected={false} />
<.pp_split_button id="send" phx-click="send">
Send
<:menu><.pp_menu_item icon="hero-clock" phx-click="schedule">Schedule</.pp_menu_item></:menu>
</.pp_split_button>
<.pp_fab icon="hero-pencil" label="Compose" position="fixed" class="bottom-4 right-4" />
<.pp_fab_menu id="create" label="Create" position="fixed" class="bottom-4 right-4">
<:item icon="hero-document" label="Document" navigate={~p"/docs/new"} />
<:item icon="hero-photo" label="Photo" on_click={JS.push("upload")} />
</.pp_fab_menu>
<.pp_card variant="filled">
<:title>Account</:title>
<:subhead>Pro plan</:subhead>
You have no pending invoices.
<:actions><.pp_button variant="text">Dismiss</.pp_button></:actions>
</.pp_card>
<.pp_typography variant="headline-medium">Release notes</.pp_typography>
<.pp_typography variant="body-medium" color="on-surface-variant">Last updated today</.pp_typography>
<.pp_chip variant="filter" toggle selected={false}>Unread</.pp_chip>
<.pp_chip variant="input" deletable on_delete={JS.push("remove_tag")}>elixir</.pp_chip>
<.pp_tooltip title="Delete">
<.pp_icon_button icon="hero-trash" label="Delete" title={false} />
</.pp_tooltip>
<.pp_menu id="more" trigger_icon="hero-ellipsis-vertical" trigger_label="More">
<.pp_menu_item icon="hero-pencil" trailing_text="⌘E" phx-click="edit">Edit</.pp_menu_item>
<.pp_menu_item icon="hero-trash" phx-click="delete">Delete</.pp_menu_item>
</.pp_menu>
<.pp_tabs id="media">
<.pp_tab id="media" value="photos" default_selected>Photos</.pp_tab>
<.pp_tab id="media" value="videos">Videos</.pp_tab>
</.pp_tabs>
<%!-- Forms: every input takes field= --%>
<.form for={@form} phx-change="validate" phx-submit="save" class="flex flex-col gap-4">
<.pp_text_field field={@form[:email]} label="Email" supporting_text="We never share it" />
<.pp_select field={@form[:country]} label="Country" options={["Canada", "Mexico"]} />
<.live_component module={PhoenixPaper.DatePicker} id="due" field={@form[:due_on]} label="Due date" />
<.live_component module={PhoenixPaper.TimePicker} id="at" field={@form[:starts_at]} label="Start time" />
<.pp_checkbox field={@form[:accept]} label="I agree to the terms" />
<.pp_switch field={@form[:notifications]} label="Notifications" icons />
<.pp_slider field={@form[:volume]} label="Volume" value_indicator />
<.pp_button type="submit">Save</.pp_button>
</.form>
<.pp_search_bar name="q" placeholder="Search mail">
<:results><.pp_list>...</.pp_list></:results>
</.pp_search_bar>
<%!-- Communication --%>
<.pp_badge content={3}><.pp_icon name="hero-bell" /></.pp_badge>
<.pp_progress value={60} />
<.pp_progress wavy />
<.pp_loading_indicator />
<.pp_flash_group flash={@flash} auto_hide_duration={4000} connection_notices />
<.pp_button phx-click={PhoenixPaper.Dialog.show("confirm")}>Delete</.pp_button>
<.pp_dialog id="confirm" icon="hero-trash">
<:title>Delete this item?</:title>
This can't be undone.
<:actions>
<.pp_button variant="text" phx-click={PhoenixPaper.Dialog.hide("confirm")}>Cancel</.pp_button>
<.pp_button variant="text" phx-click="delete">Delete</.pp_button>
</:actions>
</.pp_dialog>
<.pp_bottom_sheet id="share">...</.pp_bottom_sheet>
<.pp_side_sheet id="filters"><:title>Filters</:title>...</.pp_side_sheet>
<.pp_carousel label="Featured">
<:item :for={p <- @places} label={p.name}><img src={p.photo} alt="" class="size-full object-cover" /></:item>
</.pp_carousel>
<%!-- Bottom navigation on phones, toolbars for page actions --%>
<.pp_navigation_bar position="fixed" class="md:hidden">
<.pp_navigation_bar_item icon="hero-home" label="Home" navigate={~p"/"} active />
</.pp_navigation_bar>
<.pp_toolbar variant="floating" color="vibrant">
<.pp_icon_button icon="hero-bold" label="Bold" color="inherit" />
</.pp_toolbar>
Upgrading? The CHANGELOG lists what each release removed or renamed: 0.5.0 drops every component MD3 doesn't define, and 0.4.0 has the full 0.3 → MD3 migration table.
Every component accepts paperize={false} to drop PhoenixPaper's classes
entirely and render with only your own class; see AGENTS.md for the
full contract.
Interactive components show MD3's state layers (hover/focus/press tints)
and the focus ring, and Button, IconButton, Fab and linked items
also ripple on click; pass ripple={false} to turn the ripple off. No JS
hook involved; see PhoenixPaper.Ripple.