PhoenixPaper
A Material Design component library for Phoenix, in the spirit of ember-paper, styled with Tailwind CSS. Most individual components follow the API and behavior of MUI (Material-UI for React), adapted to Phoenix's server-rendered, stateless-function-component model.
See AGENTS.md for the framework's ground rules: the
paperize escape hatch every component supports, theming, elevation/spacing
helpers, 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.3.0) and please
report issues you run into.
Installation
Add phoenix_paper to your mix.exs deps:
def deps do
[
{:phoenix_paper, "~> 0.3.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.
Usage
<.pp_app_bar position="sticky">
<:leading><.pp_drawer_toggle for="app-drawer" /></:leading>
My App
<:actions>
<%!-- System / Light / Dark; System (follow the OS) is the default --%>
<.pp_theme_toggle />
<%!-- color="inherit" stays visible on the colored bar --%>
<.pp_button variant="icon" color="inherit" aria-label="Notifications">
<.pp_icon name="hero-bell" />
</.pp_button>
</:actions>
</.pp_app_bar>
<.pp_drawer id="app-drawer">
<:header>My App</:header>
<.pp_list dense>
<.pp_list_subheader>Main</.pp_list_subheader>
<.pp_list_item href="/" active={@current_path == "/"}>
<:leading><.pp_icon name="hero-home" /></:leading>
Home
</.pp_list_item>
<%!-- a collapsible section with a nested list --%>
<.pp_list_group id="nav-reports" default_open>
<:leading><.pp_icon name="hero-chart-bar" /></:leading>
<:label>Reports</:label>
<.pp_list_item href="/reports/sales">Sales</.pp_list_item>
<.pp_list_item href="/reports/traffic">Traffic</.pp_list_item>
</.pp_list_group>
<.pp_divider />
<.pp_list_subheader>Account</.pp_list_subheader>
<.pp_list_item href="/settings" active={@current_path == "/settings"}>
<:leading><.pp_icon name="hero-cog-6-tooth" /></:leading>
Settings
</.pp_list_item>
</.pp_list>
</.pp_drawer>
<.pp_container max_width="lg">
<.pp_grid>
<.pp_grid_item span={12} md={4}>Sidebar</.pp_grid_item>
<.pp_grid_item span={12} md={8}>Content</.pp_grid_item>
</.pp_grid>
</.pp_container>
<.pp_stack direction="row" spacing={:sm}>
<.pp_button>Save</.pp_button>
<.pp_button variant="outlined">Cancel</.pp_button>
</.pp_stack>
<.pp_image_list cols={3}>
<.pp_image_list_item src="/images/1.jpg" title="Breakfast" />
<.pp_image_list_item src="/images/2.jpg" title="Burger" subtitle="Restaurant" />
</.pp_image_list>
<.pp_button color="primary">Save</.pp_button>
<.pp_button color="primary" ripple={false}>No ripple</.pp_button>
<.pp_button href={~p"/issues"} variant="text">Issues</.pp_button>
<%!-- href/navigate/patch render an <a>, so a "button" that navigates
never nests <button> inside <a> --%>
<.pp_card>
<:title>Account</:title>
You have no pending invoices.
<:actions>
<.pp_button variant="text">Dismiss</.pp_button>
</:actions>
</.pp_card>
<%!-- the whole title + body is a link (MUI's CardActionArea) --%>
<.pp_card navigate={~p"/invoices"}>
<:title>Invoices</:title>
3 paid this month.
</.pp_card>
<.pp_typography variant="overline" color="primary">New</.pp_typography>
<.pp_typography variant="h5">Release notes</.pp_typography>
<.pp_avatar src="/images/1.jpg" alt="Remy Sharp" />
<.pp_avatar color="primary">OP</.pp_avatar>
<.pp_badge content={4}>
<.pp_icon name="hero-bell" />
</.pp_badge>
<.pp_chip>Basic</.pp_chip>
<.pp_chip deletable on_delete={JS.push("remove_tag")}>React</.pp_chip>
<.pp_tooltip title="Delete" color="error" variant="outlined" arrow>
<.pp_button variant="icon"><.pp_icon name="hero-trash" /></.pp_button>
</.pp_tooltip>
<%!-- client-side toggles: no handle_event needed --%>
<.pp_toggle_button toggle pressed>Bold</.pp_toggle_button>
<.pp_button_group>
<.pp_toggle_button toggle_group="view" pressed>List</.pp_toggle_button>
<.pp_toggle_button toggle_group="view">Grid</.pp_toggle_button>
</.pp_button_group>
<.pp_collapse id="advanced">
<:trigger>Advanced options</:trigger>
<.pp_input name="timeout" label="Timeout" />
</.pp_collapse>
<%!-- A trigger that opens a small anchored popover of actions --%>
<.pp_menu id="profile-menu">
<:trigger><.pp_icon name="hero-ellipsis-vertical" /></:trigger>
<.pp_list>
<.pp_list_item navigate={~p"/profile"}>Profile</.pp_list_item>
<.pp_list_item phx-click="log_out">Log out</.pp_list_item>
</.pp_list>
</.pp_menu>
<.pp_paper elevation={2} class="p-4">A raised surface (Card is built on this).</.pp_paper>
<.pp_typography variant="h4">Account settings</.pp_typography>
<.pp_typography variant="caption">Last updated 2 minutes ago</.pp_typography>
<%!-- pp_form = Phoenix's <.form> plus field spacing and an actions row --%>
<.pp_form for={@form} phx-change="validate" phx-submit="save">
<.pp_input field={@form[:email]} label="Email" />
<.pp_select field={@form[:country]} label="Country" options={["Canada", "Mexico"]} />
<.pp_input field={@form[:starts_at]} type="datetime-local" label="Starts at" />
<:actions>
<.pp_button type="submit">Save</.pp_button>
</:actions>
</.pp_form>
<%!-- page numbers as patch links (or on_change="paginate" for events) --%>
<.pp_pagination page={@page} count={@total_pages} path={&~p"/users?page=#{&1}"} />
<%!-- a table footer: rows per page, "11–20 of 47", prev/next --%>
<.pp_table_pagination
id="users-pagination"
page={@page}
count={@total_rows}
rows_per_page={@per_page}
path={&~p"/users?page=#{&1}&per_page=#{&2}"}
/>
<%!-- hide_label: dense, unwrapped variant for an inline filter toolbar --%>
<.pp_input hide_label label="Search" name="q" size="small" />
<.pp_select hide_label label="Status" name="status" prompt="Any" options={["Active", "Archived"]} />
<.pp_number_field field={@form[:quantity]} label="Quantity" min={0} max={10} />
<.pp_checkbox field={@form[:accept]} label="I agree to the terms" />
<.pp_switch field={@form[:notifications]} label="Notifications" />
<.pp_radio_group field={@form[:size]} label="Size" options={[{"Small", "sm"}, {"Large", "lg"}]} />
<.pp_slider name="volume" label="Volume" value={60} />
<.pp_rating id="stars" name="stars" value={3} />
<.pp_button_group>
<.pp_button variant="outlined">Day</.pp_button>
<.pp_button variant="outlined">Week</.pp_button>
</.pp_button_group>
<.pp_fab position="fixed" class="bottom-6 right-6">+</.pp_fab>
<%!-- A FAB that fans out related actions on hover / click / focus — pure CSS --%>
<.pp_speed_dial id="create" label="Create" position="fixed" class="bottom-6 right-6">
<:action label="New workbook" navigate={~p"/workbooks/new"}>
<.pp_icon name="hero-document-plus" />
</:action>
<:action label="Invite teammate" on_click={JS.push("open_invite")}>
<.pp_icon name="hero-user-plus" />
</:action>
</.pp_speed_dial>
<.pp_icon name="hero-check" />
<%!-- Phoenix flash (@flash) as Material snackbars — drop once in the root
layout, where a generated <.flash_group> would go --%>
<.pp_flash_group flash={@flash} />
<.pp_flash_group flash={@flash} auto_hide_duration={4000} />
<%!-- also the "connection lost" chips, replacing the generated <.flash_group> --%>
<.pp_flash_group flash={@flash} connection_notices />
<%!-- Autocomplete, PowerSelect and TransferList need interactive state, so
they're Phoenix.LiveComponents (LiveView only) instead of pp_* functions --%>
<.live_component module={PhoenixPaper.Autocomplete} id="country" name="country" label="Country" options={["Canada", "Mexico"]} />
<%!-- PowerSelect: a searchable select (ember-power-select) — accent-insensitive
search, keyboard nav, groups, server search, and multiple selection --%>
<.live_component
module={PhoenixPaper.PowerSelect}
id="assignees"
field={@form[:assignee_ids]}
label="Assignees"
multiple
search={&MyApp.Accounts.search_users/1}
label_field={:name}
value_field={:id}
/>
<.live_component module={PhoenixPaper.TransferList} id="permissions" items={["Read", "Write", "Admin"]} />
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.
Button, Fab, ToggleButton, and a linked ListItem ripple on
click/tap by default (the classic Material feedback effect); pass
ripple={false} to turn it off. No JS hook or asset pipeline involved; see
PhoenixPaper.Ripple.