PhoenixPaper

PhoenixPaper

Hex.pm Hexdocs License

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. It started in the spirit of ember-paper; since 0.4.0 it follows the MD3 spec, with MUI-style APIs where MD3 doesn't specify a component (tables, pagination, autocomplete), 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, 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.4.0) and please report issues you run into.

Installation

Add phoenix_paper to your mix.exs deps:

def deps do
  [
    {:phoenix_paper, "~> 0.4.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.

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.

Optional: the JS hook

Everything works without JavaScript beyond Phoenix's own. One optional LiveView hook adds what CSS can't do everywhere: the sliding tab indicator, drag-to-dismiss bottom sheets, the scrolled top app bar and carousel masking in Firefox, and the loading indicator's morph in Safari.

// assets/js/app.js
import PhoenixPaperHooks from "phoenix_paper"
const liveSocket = new LiveSocket("/live", Socket, {hooks: {...PhoenixPaperHooks}, ...})
# config/config.exs
config :phoenix_paper, hook: true

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 --%>
<.pp_form for={@form} phx-change="validate" phx-submit="save">
  <.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 />
  <:actions><.pp_button type="submit">Save</.pp_button></:actions>
</.pp_form>

<.pp_search_bar name="q" placeholder="Search mail">
  <:results><.pp_list>...</.pp_list></:results>
</.pp_search_bar>

<%!-- Feedback --%>
<.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>

<%!-- Tables, pagination, layout and the LiveComponents (Autocomplete,
      PowerSelect, TransferList) are unchanged in shape; see their docs --%>
<.pp_pagination page={@page} count={@total_pages} path={&~p"/users?page=#{&1}"} />

Upgrading from 0.3? The CHANGELOG has the full migration table: every renamed component, attr and value.

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.