Current section

Files

Jump to
phoenix_paper README.md
Raw

README.md

<p align="center">
  <img src="priv/static/images/logo/phoenixpaper-lockup-card.svg" alt="PhoenixPaper" width="320">
</p>

# PhoenixPaper

[![Hex.pm](https://img.shields.io/hexpm/v/phoenix_paper.svg)](https://hex.pm/packages/phoenix_paper)
[![Hexdocs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/phoenix_paper)
[![License](https://img.shields.io/hexpm/l/phoenix_paper.svg)](https://github.com/z7ealth/phoenix_paper/blob/master/LICENSE)

A [Material Design 3](https://m3.material.io/) 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](https://m3.material.io/components) 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`](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](CHANGELOG.md)). 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](https://github.com/z7ealth/phoenix_paper/issues) you run into.

## Installation

Add `phoenix_paper` to your `mix.exs` deps:

```elixir
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`:

```elixir
defp html_helpers do
  quote do
    use PhoenixPaper.Components
    # ...
  end
end
```

And wire up the Tailwind theme in `assets/css/app.css`:

```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):

```js
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`):

```html
<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:

```sh
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](https://material-foundation.github.io/material-theme-builder/)
works too: paste its roles as `--color-pp-*` overrides.

## Usage

```heex
<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](CHANGELOG.md) 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`.