PhoenixPaper
Live demo → · Components · Theme creator
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, or a composite built only from MD3 parts for something common MD3 leaves open (data tables, pagination, breadcrumbs, autocomplete with input chips, number and password fields, file uploads). MD3's canonical pane layouts are included; any other layout is 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.4) and please
report issues you run into.
Installation
Add phoenix_paper to your mix.exs deps:
def deps do
[
{:phoenix_paper, "~> 0.5.4"}
]
end
PhoenixPaper requires phoenix_live_view ~> 1.1 (it uses
Phoenix.Component.dynamic_tag/1, new in 1.1). Apps generated before
Phoenix 1.8 usually have an older LiveView locked, so if mix deps.get
fails to resolve, set {:phoenix_live_view, "~> 1.1"} in your own deps
and run mix deps.update phoenix_live_view.
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.
For a named palette (Catppuccin, Nord, a brand guide) that already says
which hex each role gets, put the roles in a JSON file and pass
--palette:
mix phoenix_paper.gen.theme --palette catppuccin.json # every role given
mix phoenix_paper.gen.theme --seed "#8839ef" --palette brand.json # the seed fills the rest
{
"light": {"primary": "#8839ef", "on-primary": "#eff1f5", "surface": "#eff1f5"},
"dark": {"primary": "#cba6f7", "on-primary": "#1e1e2e", "surface": "#1e1e2e"}
}
Keys are the role names below (without the --color-pp- prefix, though
it's accepted). The dark values are written to both the
[data-theme="dark"] block and the prefers-color-scheme block.
The color roles are MD3's and only MD3's: primary, secondary,
tertiary and error (each with on-*, *-container,
on-*-container and the fixed roles), the surface scale, outline,
outline-variant, the inverse roles, shadow and scrim. There is no
success, warning or info, because MD3 has none (0.5.0 removed
them). Map states onto the roles MD3 has: error/error-container for
anything that needs attention, and primary, tertiary or their
containers for positive or neutral states. If your app really needs a
status color, define it as your own token in app.css outside the
pp- namespace (e.g. --color-app-success) so a theme regeneration
never touches it.
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" />
<.live_component module={PhoenixPaper.Autocomplete} id="city" field={@form[:city]} label="City" options={@cities} />
<.pp_number_field field={@form[:seats]} label="Seats" min="1" />
<.pp_password_field field={@form[:password]} label="Password" autocomplete="new-password" />
<.live_component module={PhoenixPaper.Autocomplete} id="tags" field={@form[:tags]} label="Tags" options={@tags} multiple />
<.pp_upload upload={@uploads.photos} on_cancel="cancel_upload" supporting_text="JPG or PNG, up to 8 MB" />
<.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>
<%!-- MD3 canonical layouts: list-detail and supporting pane --%>
<.pp_list_detail show_detail={@message != nil}>
<:list><.pp_list>...</.pp_list></:list>
<:detail>...</:detail>
</.pp_list_detail>
<%!-- Data: composites of MD3 parts --%>
<.pp_breadcrumbs>
<:item navigate={~p"/"}>Home</:item>
<:item>Users</:item>
</.pp_breadcrumbs>
<.pp_table_container>
<.pp_table>
<.pp_table_head>
<.pp_table_row>
<.pp_table_cell variant="head" sortable sort_direction="asc" phx-click="sort">Name</.pp_table_cell>
<.pp_table_cell variant="head" align="right">Role</.pp_table_cell>
</.pp_table_row>
</.pp_table_head>
<.pp_table_body>
<.pp_table_row :for={u <- @users} selected={u.id in @selected}>
<.pp_table_cell>
<span class="flex items-center gap-3"><.pp_avatar>{initials(u)}</.pp_avatar>{u.name}</span>
</.pp_table_cell>
<.pp_table_cell align="right">{u.role}</.pp_table_cell>
</.pp_table_row>
</.pp_table_body>
</.pp_table>
<.pp_table_pagination id="users-pages" page={@page} count={@total} rows_per_page={@per_page}
path={&~p"/users?page=#{&1}&per_page=#{&2}"} />
</.pp_table_container>
<.pp_pagination page={@page} count={@pages} path={&~p"/posts?page=#{&1}"} />
<%!-- 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 dropped every component MD3 doesn't define,
0.5.2 brought back tables, pagination, breadcrumbs, autocomplete, number
fields and avatars rebuilt from MD3 parts (and added password fields,
input-chip multi-select, uploads and pane layouts), 0.5.3 made cards
fill their container and host lists edge to edge and added --palette
to the theme generator, 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.