Packages
Tailwind CSS and Alpine.js UI components for Phoenix LiveView. All 42 Pines elements: modal, dropdown, command palette, toast, table, date picker, tabs. Accessible, dark mode, AI-agent ready.
Current section
Files
Jump to
Current section
Files
pine_ui_phoenix
usage-rules.md
usage-rules.md
# Pine UI usage rules
Pine UI is a Phoenix LiveView port of the [Pines](https://devdojo.com/pines) component
library (Alpine.js + Tailwind CSS). It provides 49 function components covering all 42 Pines
elements.
These rules exist so you can use the library correctly without reading the full docs. Follow
the install checklist before writing any markup — three of the four steps are load-bearing,
and skipping them produces components that render unstyled or flash on load.
## Install checklist
```elixir
# mix.exs
{:pine_ui_phoenix, "~> 0.2.0"}
```
Requires Elixir 1.15+ and `phoenix_live_view >= 0.20.4` (covers 0.20.x through 1.2).
**1. Tailwind must scan the package.** Without this every class is purged in production and
components render completely unstyled, with no error.
```js
// Tailwind v3 — assets/tailwind.config.js
content: [..., "../deps/pine_ui_phoenix/lib/**/*.ex"]
```
```css
/* Tailwind v4 — assets/css/app.css */
@source "../../deps/pine_ui_phoenix/lib";
```
**2. Import the stylesheet.** Carries the `[x-cloak]` rule; without it every dropdown, modal
and tooltip flashes its contents on page load.
```css
@import "../../deps/pine_ui_phoenix/priv/static/pine_ui.css";
```
**3. Install Alpine and wire `pineDom()`.** Without it Alpine state is destroyed on every
LiveView DOM patch — an open dropdown closes when any unrelated render happens.
```js
import Alpine from "alpinejs"
import focus from "@alpinejs/focus"
import { pineDom, PineHooks } from "../../deps/pine_ui_phoenix/priv/static/pine_ui.js"
Alpine.plugin(focus)
window.Alpine = Alpine
Alpine.start()
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: { ...PineHooks },
dom: pineDom()
})
```
`@alpinejs/focus` is required by `modal`, `full_screen_modal`, `slide_over` and `command`.
**4. Import the components** in `html_helpers/0`, after `use Phoenix.Component`:
```elixir
use Phoenix.Component
use PineUiPhoenix
```
## Rules
**Name collisions with CoreComponents.** A stock Phoenix app already defines `<.button>`,
`<.table>`, `<.modal>` and `<.flash>`. Importing both is a compile error. Resolve it:
```elixir
use PineUiPhoenix, except: [:button, :table, :modal]
use PineUiPhoenix, only: [:command, :marquee, :rating]
use PineUiPhoenix, prefix: :pine # <.pine_button>, <.pine_table>
```
Prefer `only:`/`except:` — `prefix:` generates delegates and loses compile-time attribute
validation.
**Alpine shorthands do not work in HEEx.** Write the long form. `@` starts an assign and `:`
starts a special attribute, so neither can be an attribute name:
```heex
<.card x-on:click="open = true"> <!-- correct -->
<.card @click="open = true"> <!-- INVALID, will not compile -->
<.card x-bind:class="open && 'ring'"> <!-- correct -->
<.card :class="open && 'ring'"> <!-- INVALID -->
```
`use PineUiPhoenix` registers `x-` as a global attribute prefix on your module, so any
component accepts Alpine directives.
**Open overlays by dispatching an event, not by setting an assign.** `modal`,
`full_screen_modal`, `slide_over` and `command` listen for `pine:open`, `pine:close` and
`pine:toggle` on themselves. There is no `show` assign to toggle from the server.
```heex
<.button phx-click={JS.dispatch("pine:open", to: "#confirm")}>Delete</.button>
<.modal id="confirm" title="Are you sure?">This cannot be undone.</.modal>
```
From a LiveView (requires `phx-hook="PineBridge"` mounted once on `<body>`):
```elixir
push_event(socket, "pine:dispatch", %{to: "#confirm", event: "pine:close"})
```
The `open` attribute only sets the *initial* state at render time.
**Toasts are pushed to a single group.** Put `<.toast_group />` once in the root layout, then:
```elixir
{:noreply, PineUiPhoenix.Components.Toast.push(socket, "Saved", type: "success")}
```
There is no standalone toast component to render inline.
**Classes merge — yours win.** `class` is resolved against the defaults, so
`<.button class="bg-red-500">` genuinely overrides the default background. Do not fight it
with `!important` or by wrapping in a styled div. Per-part attributes (`header_class`,
`panel_class`, `menu_class`, …) *replace* that part's classes outright.
**Multi-select is `select_menu multiple`,** not `select multiple`. The native `select` does
support `multiple`, but `select_menu multiple` is the one with chips, search and a cap:
```heex
<.select_menu name="tags" label="Tags" multiple searchable max={3}
value={@tags} options={@all_tags} />
```
It submits as `tags[]`. Read it back with `params["tags"]`, which is a list.
**`select` options are `{label, value}` tuples**, matching `options_for_select/2`:
```heex
<.select name="country" options={[{"United States", "us"}, {"Canada", "ca"}]} value={@country} />
```
Grouped options nest: `[{"North America", [{"Toronto", "yyz"}]}]`.
**`title` shadows the HTML attribute.** Components that take a `title` attribute (card,
modal, alert, accordion items) use it as heading text. For a native tooltip, pass
`rest={%{title: "..."}}`.
**Ids are generated automatically** unless you pass one. `modal`, `full_screen_modal`,
`slide_over` and `command` **require** an explicit `id` — that is how events target them.
**Do not use the deprecated v0.1.x names.** `button_primary/1`, `badge_dot/1`,
`modal_side/1`, `data_table/1` and ~48 others still work but are deprecated adapters. Always
use the current names from the table below.
**Dark mode works automatically.** Every component ships `dark:` variants; nothing to
configure beyond your Tailwind `darkMode` strategy.
## Components
`*` marks a required attribute or slot. Every component also accepts `class` and any global
attribute (`phx-*`, `x-*`, `aria-*`, `data-*`).
<!-- component-table:start -->
| Component | Attributes | Slots |
|---|---|---|
| `accordion` | `animate=transition\|collapse\|none` `default_open` `id` `multiple` | `:item\*` |
| `alert` | `dismissible` `show_icon` `title` `variant=default\|success\|warning\|danger\|info` | `:icon` `:inner_block` |
| `badge` | `dismissible` `dot` `variant=default\|primary\|secondary\|success\|warning\|danger\|info\|outline` | `:inner_block\*` |
| `banner` | `delay` `dismissible` `id` `position=top\|bottom` `sticky` `variant=default\|primary\|success\|warning\|danger` | `:actions` `:inner_block\*` |
| `breadcrumbs` | `label` `separator=chevron\|slash\|dot` | `:crumb\*` |
| `button` | `disabled` `loading` `size=sm\|md\|lg\|icon` `type=button\|submit\|reset` `variant=primary\|secondary\|destructive\|outline\|ghost\|link` | `:icon` `:inner_block\*` |
| `card` | `body_class` `footer_class` `header_class` `interactive` `padded` `subtitle` `title` | `:footer` `:header` `:inner_block\*` |
| `checkbox` | `checked` `description` `disabled` `error` `hidden_input` `id` `label` `name\*` `required` `value` | — |
| `combobox` | `allow_custom` `container_class` `disabled` `empty_message` `error` `hint` `id` `label` `name\*` `options` `placeholder` `required` `value` | — |
| `tags_input` | `container_class` `disabled` `error` `hint` `id` `label` `max` `name\*` `placeholder` `separator` `value` | — |
| `command` | `empty_message` `filter` `id\*` `open` `placeholder` `shortcut` | `:footer` `:group` `:item` |
| `context_menu` | `id` `menu_class` `width` | `:item` `:trigger\*` |
| `copy_to_clipboard` | `copied_label` `id` `label` `text\*` `timeout` `variant=button\|icon\|inline` | `:inner_block` |
| `data_table` | `caption` `columns\*` `data\*` `dense` `empty_state` `hoverable` `id` `row_click` `striped` | — |
| `data_table_expandable` | `caption` `columns\*` `data\*` `empty_state` `id` | `:expanded_row\*` |
| `date_picker` | `container_class` `disabled` `error` `first_day_of_week` `format` `hint` `id` `label` `max` `min` `month` `name\*` `on_navigate` `placeholder` `required` `value` | — |
| `dropdown_menu` | `id` `label` `menu_class` `position=bottom-start\|bottom-end\|top-start\|top-end` `width` | `:header` `:item` `:trigger` |
| `file_upload` | `accept` `disabled` `error` `hint` `id` `label` `multiple` `name` `on_cancel` `preview_height` `upload` `variant=default\|image` | `:inner_block` |
| `hover_card` | `close_delay` `id` `open_delay` `panel_class` `position=top\|bottom\|left\|right` `width` | `:inner_block\*` `:trigger\*` |
| `image_gallery` | `aspect=square\|video\|wide\|auto` `columns=1\|2\|3\|4\|5\|6` `gap` `id` `images\*` `layout=grid\|masonry` `lightbox` | — |
| `carousel` | `aspect=square\|video\|wide\|auto` `autoplay` `id` `images\*` | — |
| `marquee` | `direction=left\|right` `duration` `fade_edges` `gap` `pause_on_hover` `vertical` | `:inner_block\*` |
| `retro_grid` | `angle` `cell_size` `duration` | — |
| `video` | `aspect=video\|square\|wide\|auto` `autoplay` `captions_label` `captions_src` `id` `loop` `muted` `poster` `src` | `:source` |
| `monaco_editor` | `cdn_base` `font_size` `height` `id` `label` `language` `name\*` `theme=vs\|vs-dark\|hc-black` `value` | — |
| `menubar` | `id` | `:menu\*` |
| `modal` | `close_on_backdrop` `close_on_escape` `description` `id\*` `open` `show_close_button` `size=sm\|md\|lg\|xl\|2xl\|full` `title` | `:footer` `:header` `:inner_block\*` |
| `full_screen_modal` | `close_on_escape` `id\*` `open` `title` | `:header` `:inner_block\*` |
| `slide_over` | `close_on_backdrop` `close_on_escape` `description` `id\*` `open` `position=left\|right` `size=sm\|md\|lg\|xl\|full` `title` | `:footer` `:inner_block\*` |
| `navigation_menu` | `close_delay` `id` | `:item\*` |
| `pagination` | `label` `on_select` `page\*` `path` `show_edges` `siblings` `total_pages\*` | — |
| `popover` | `align=start\|center\|end` `arrow` `id` `panel_class` `position=top\|bottom\|left\|right` `width` | `:inner_block\*` `:title` `:trigger\*` |
| `progress` | `bar_class` `indeterminate` `label` `max` `show_value` `size=sm\|md\|lg` `value\*` `variant=default\|success\|warning\|danger` | — |
| `progress_circle` | `label` `max` `show_value` `size` `stroke` `value\*` `variant=default\|success\|warning\|danger` | — |
| `progress_steps` | `current` `steps\*` | — |
| `blockquote` | `author` `avatar` `role` `variant=bordered\|plain\|card` | `:inner_block\*` |
| `radio_group` | `columns` `disabled` `error` `hint` `id` `label` `name\*` `value` | `:option\*` |
| `range_slider` | `container_class` `disabled` `hint` `id` `label` `max` `min` `name\*` `show_value` `step` `value` `value_suffix` | — |
| `rating` | `id` `interactive` `label` `max` `name` `show_value` `size=sm\|md\|lg` `value` | — |
| `select` | `container_class` `disabled` `error` `hint` `id` `label` `multiple` `name\*` `options` `prompt` `required` `value` | — |
| `select_menu` | `clearable` `container_class` `disabled` `empty_message` `error` `hint` `id` `label` `max` `multiple` `name\*` `options` `prompt` `searchable` `value` | — |
| `switch` | `checked` `description` `disabled` `id` `label` `label_position=left\|right` `name` `size=sm\|md\|lg` | — |
| `table` | `caption` `dense` `empty_message` `hoverable` `row_click` `row_id` `rows\*` `striped` | `:action` `:col\*` `:empty` |
| `tabs` | `default_tab` `full_width` `id` `list_class` `panel_class` `variant=underline\|pills\|boxed` | `:tab\*` |
| `typing_effect` | `cursor` `erase_speed` `id` `loop` `pause_end` `text_class` `texts\*` `type_speed` | — |
| `text_animation` | `effect=fade\|blow\|slide` `id` `stagger` `text\*` | — |
| `text_input` | `container_class` `disabled` `error` `hint` `id` `label` `name\*` `placeholder` `prefix` `readonly` `required` `suffix` `type=text\|email\|password\|search\|tel\|url\|number\|date\|datetime-local\|month\|time\|week\|color` `value` | `:icon` `:trailing` |
| `textarea` | `auto_resize` `container_class` `disabled` `error` `hint` `id` `label` `name\*` `placeholder` `readonly` `required` `rows` `value` | — |
| `toast_group` | `duration` `id` `max` `position=top-left\|top-center\|top-right\|bottom-left\|bottom-center\|bottom-right` | — |
| `toast_trigger` | `description` `duration` `label` `title\*` `type=default\|success\|error\|warning` | `:inner_block` |
| `tooltip` | `delay` `id` `position=top\|bottom\|left\|right` `text\*` `tooltip_class` | `:inner_block\*` |
<!-- component-table:end -->
## Which component to reach for
| Need | Use |
|---|---|
| A dialog | `modal` — or `slide_over` for a side panel, `full_screen_modal` for immersive |
| A menu from a button | `dropdown_menu` |
| A menu from right-click | `context_menu` |
| Rich content on click | `popover` — on hover, `hover_card` — plain text label, `tooltip` |
| Notifications | `toast_group` + `Toast.push/3` |
| An inline message | `alert` |
| A page-wide announcement | `banner` |
| Tabular data | `table` (slot-based, preferred) or `data_table` (column maps) |
| A ⌘K search palette | `command` |
| Collapsible sections | `accordion` |
| Selecting one of several | `radio_group`, `select`, or `select_menu searchable` |
| Selecting several | `select_menu multiple` — chips, optional `max`, submits as `name[]` |
| A value that may not be in the list | `combobox` (free text allowed) |
| Free-form tags | `tags_input` |
| A boolean | `switch` (settings) or `checkbox` (forms) |
| File uploads | `file_upload` with `upload={@uploads.name}` |
## Common mistakes
- Writing `@click` instead of `x-on:click`.
- Forgetting the Tailwind `content`/`@source` path, then reporting "the components have no styling".
- Forgetting `dom: pineDom()`, then reporting "the dropdown closes by itself".
- Trying to open a modal with `<.modal open={@show}>` and a server assign — use `JS.dispatch`.
- Passing `{value, label}` to `select` — it takes `{label, value}`.
- Importing Pine UI alongside CoreComponents without `except:`/`only:`/`prefix:`.
- Placing `use PineUiPhoenix` before `use Phoenix.Component`.
## Reference
- Docs: https://hexdocs.pm/pine_ui_phoenix
- Guides: Installation, LiveView integration, Theming, Upgrading to v0.2
- Upstream design: https://devdojo.com/pines