Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

Welcome to Petal Components

Shadcn-style Phoenix components that AI assistants can actually use.

Composable HEEx primitives (<.button>, <.modal>, <.table>, …), built on Tailwind v4, working in both LiveView and dead views. Plus a hosted MCP server so Claude Code, Cursor, Codex, and Windsurf write idiomatic petal_components instead of inventing raw Tailwind markup.

Live demo · GitHub · Hex · vs shadcn

Install

There are two ways to install. The first is fast and works for any AI-driven project. The second is the classic manual setup.

The two-step way (recommended)

1. Install the MCP server (once)

claude mcp add petal --transport http https://mcp.petal.build/mcp

For other AI coding tools (Cursor, Codex, Windsurf, Continue, Cline), see the per-tool setup snippets on the petal-components landing.

2. In your Phoenix project, tell your AI:

install petal_components

The agent calls get_install_instructions on the MCP, then patches your mix.exs, assets/css/app.css, and lib/<your_app>_web.ex to add the dependency, the Tailwind imports, and use PetalComponents. Steps are idempotent. Works in umbrella apps too.

It can also call list_components and get_component <name> to read the real component schema before writing any HEEx, so the attrs and slots match what’s actually shipped.


Manual install

Prefer to do it by hand, or working in a project without an AI coding tool? Follow these steps.

Add the dependency

mix.exs
defp deps do
[
{:petal_components, "~> 4.0"},
]
end

Then mix deps.get.

Configure and install Tailwind 4.0.9+

The default Tailwind setup for Phoenix installs Tailwind 4.0.0. Make sure it’s set to 4.0.9 or above:

config :tailwind,
version: "4.0.9",
default: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/app.css
),
cd: Path.expand("..", __DIR__)
]

Run:

mix tailwind.install

CSS configuration

In Tailwind 4, tailwind.config.js is legacy. Configuration goes in CSS. Update app.css:

@import "tailwindcss";
@source "../../deps/petal_components/**/*.*ex";
@import "../../deps/petal_components/assets/default.css";
@import "./colors.css";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@plugin "./tailwind_heroicons.js";

Line by line:

@source "../../deps/petal_components/**/*.*ex";

Tells Tailwind to scan the petal_components source for Tailwind utility usage. Required for the JIT compilation to pick up classes used inside components.

@import "../../deps/petal_components/assets/default.css";

Brings in the default petal_components styles (the pc-* CSS class prefix used for component styling).

@import "./colors.css";

Custom semantic colors (primary, secondary, success, danger, etc.) used throughout the components. You’ll create this file below.

@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@plugin "./tailwind_heroicons.js";

Typography, Forms, and Heroicons plugins. tailwind_heroicons.js is a custom plugin you’ll create below.


Create colors.css

petal_components reads semantic color variables. Create assets/css/colors.css:

@theme inline {
--color-primary-50: var(--color-blue-50);
--color-primary-100: var(--color-blue-100);
--color-primary-200: var(--color-blue-200);
--color-primary-300: var(--color-blue-300);
--color-primary-400: var(--color-blue-400);
--color-primary-500: var(--color-blue-500);
--color-primary-600: var(--color-blue-600);
--color-primary-700: var(--color-blue-700);
--color-primary-800: var(--color-blue-800);
--color-primary-900: var(--color-blue-900);
--color-primary-950: var(--color-blue-950);
--color-secondary-50: var(--color-pink-50);
--color-secondary-100: var(--color-pink-100);
--color-secondary-200: var(--color-pink-200);
--color-secondary-300: var(--color-pink-300);
--color-secondary-400: var(--color-pink-400);
--color-secondary-500: var(--color-pink-500);
--color-secondary-600: var(--color-pink-600);
--color-secondary-700: var(--color-pink-700);
--color-secondary-800: var(--color-pink-800);
--color-secondary-900: var(--color-pink-900);
--color-secondary-950: var(--color-pink-950);
--color-success-50: var(--color-green-50);
--color-success-100: var(--color-green-100);
--color-success-200: var(--color-green-200);
--color-success-300: var(--color-green-300);
--color-success-400: var(--color-green-400);
--color-success-500: var(--color-green-500);
--color-success-600: var(--color-green-600);
--color-success-700: var(--color-green-700);
--color-success-800: var(--color-green-800);
--color-success-900: var(--color-green-900);
--color-success-950: var(--color-green-950);
--color-danger-50: var(--color-red-50);
--color-danger-100: var(--color-red-100);
--color-danger-200: var(--color-red-200);
--color-danger-300: var(--color-red-300);
--color-danger-400: var(--color-red-400);
--color-danger-500: var(--color-red-500);
--color-danger-600: var(--color-red-600);
--color-danger-700: var(--color-red-700);
--color-danger-800: var(--color-red-800);
--color-danger-900: var(--color-red-900);
--color-danger-950: var(--color-red-950);
--color-warning-50: var(--color-yellow-50);
--color-warning-100: var(--color-yellow-100);
--color-warning-200: var(--color-yellow-200);
--color-warning-300: var(--color-yellow-300);
--color-warning-400: var(--color-yellow-400);
--color-warning-500: var(--color-yellow-500);
--color-warning-600: var(--color-yellow-600);
--color-warning-700: var(--color-yellow-700);
--color-warning-800: var(--color-yellow-800);
--color-warning-900: var(--color-yellow-900);
--color-warning-950: var(--color-yellow-950);
--color-info-50: var(--color-sky-50);
--color-info-100: var(--color-sky-100);
--color-info-200: var(--color-sky-200);
--color-info-300: var(--color-sky-300);
--color-info-400: var(--color-sky-400);
--color-info-500: var(--color-sky-500);
--color-info-600: var(--color-sky-600);
--color-info-700: var(--color-sky-700);
--color-info-800: var(--color-sky-800);
--color-info-900: var(--color-sky-900);
--color-info-950: var(--color-sky-950);
--color-gray-50: var(--color-slate-50);
--color-gray-100: var(--color-slate-100);
--color-gray-200: var(--color-slate-200);
--color-gray-300: var(--color-slate-300);
--color-gray-400: var(--color-slate-400);
--color-gray-500: var(--color-slate-500);
--color-gray-600: var(--color-slate-600);
--color-gray-700: var(--color-slate-700);
--color-gray-800: var(--color-slate-800);
--color-gray-900: var(--color-slate-900);
--color-gray-950: var(--color-slate-950);
}

Swap any of these for your brand palette. Components read the semantic names, not the underlying Tailwind colors.


Create tailwind_heroicons.js

Embeds Heroicons (https://heroicons.com) into your CSS bundle. Create assets/tailwind_heroicons.js:

const plugin = require("tailwindcss/plugin");
const fs = require("fs");
const path = require("path");
// Embeds Heroicons (https://heroicons.com) into your app.css bundle
// See your `CoreComponents.icon/1` for more information.
module.exports = plugin(function ({ matchComponents, theme }) {
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized");
let values = {};
let icons = [
["", "/24/outline"],
["-solid", "/24/solid"],
["-mini", "/20/solid"],
["-micro", "/16/solid"],
];
icons.forEach(([suffix, dir]) => {
fs.readdirSync(path.join(iconsDir, dir)).forEach((file) => {
let name = path.basename(file, ".svg") + suffix;
values[name] = { name, fullPath: path.join(iconsDir, dir, file) };
});
});
matchComponents(
{
hero: ({ name, fullPath }) => {
let content = fs
.readFileSync(fullPath)
.toString()
.replace(/\r?\n|\r/g, "");
let size = theme("spacing.6");
if (name.endsWith("-mini")) {
size = theme("spacing.5");
} else if (name.endsWith("-micro")) {
size = theme("spacing.4");
}
return {
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
"-webkit-mask": `var(--hero-${name})`,
mask: `var(--hero-${name})`,
"mask-repeat": "no-repeat",
"background-color": "currentColor",
"vertical-align": "middle",
display: "inline-block",
width: size,
height: "1lh",
};
},
},
{ values },
);
});

Import the components

In your web module, use PetalComponents inside the html helpers. This imports every component so you can call them as <.button>, <.modal>, etc. without explicit aliases.

<your_app>_web.ex
defmodule YourAppWeb
...
defp html_helpers do
quote do
...
use PetalComponents
end
end

That’s the minimum manual setup. Compile and try <.button>Hello</.button> in any template.


Additional setup

JS hooks (for interactive components)

As of v4, Petal Components no longer uses Alpine.js. The overlay components (dropdown, accordion, menu, modal, slide over) use Phoenix.LiveView.JS and need no setup. The password/copyable/clearable inputs and the Chat family use bundled JS hooks - import them once and merge them into your LiveSocket:

assets/js/app.js
import PetalComponents from "../../deps/petal_components/assets/js/petal_components"
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: { ...PetalComponents }, // merge with your own: { ...MyHooks, ...PetalComponents }
})

VSCode snippets

Core Components conflicts (Phoenix 1.7+)

The Phoenix generator creates core_components.ex with some function components defined: modal, button, table, input, label, flash, flash_group. These clash with petal_components of the same name.

Fix: rename or remove those functions from core_components.ex.

Phoenix generators like mix phx.gen.live won’t work properly without the rename. Workarounds documented here. For generators that match petal_components out of the box, Petal Pro ships its own.

For a full upgrade commit of Phoenix 1.6 to 1.7, see how we did it in Petal Boilerplate.

If you want to pick and choose which components to use instead of use PetalComponents:

import PetalComponents.{
Alert,
Badge,
Button,
Container,
Dropdown,
Form,
Loading,
Typography,
Avatar,
Progress,
Breadcrumbs,
Pagination,
Link,
Modal,
SlideOver,
Tabs,
Card,
Table,
Accordion,
Icon
}

Translate form errors

To translate form errors to anything other than English, point petal_components at your translator function:

config.exs
config :petal_components, :error_translator_function, {<YourApp>Web.ErrorHelpers, :translate_error}

Namespacing

You can namespace all the components under any module you like. For example:

# in your_app_web.ex
defp html_helpers do
quote do
# HTML escaping functionality
import Phoenix.HTML
# Core UI components and translation
import YourAppWeb.CoreComponents
import YourAppWeb.Gettext
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS
# Routes generation with the ~p sigil
unquote(verified_routes())
# Petal Components
defmodule PC do
defdelegate accordion(assigns), to: PetalComponents.Accordion
defdelegate alert(assigns), to: PetalComponents.Alert
defdelegate avatar(assigns), to: PetalComponents.Avatar
defdelegate badge(assigns), to: PetalComponents.Badge
defdelegate breadcrumbs(assigns), to: PetalComponents.Breadcrumbs
defdelegate button(assigns), to: PetalComponents.Button
defdelegate icon_button(assigns), to: PetalComponents.Button
defdelegate card(assigns), to: PetalComponents.Card
defdelegate container(assigns), to: PetalComponents.Container
defdelegate dropdown(assigns), to: PetalComponents.Dropdown
defdelegate form_label(assigns), to: PetalComponents.Form
defdelegate field(assigns), to: PetalComponents.Field
defdelegate icon(assigns), to: PetalComponents.Icon
defdelegate input(assigns), to: PetalComponents.Input
defdelegate a(assigns), to: PetalComponents.Link
defdelegate spinner(assigns), to: PetalComponents.Loading
defdelegate modal(assigns), to: PetalComponents.Modal
defdelegate pagination(assigns), to: PetalComponents.Pagination
defdelegate progress(assigns), to: PetalComponents.Progress
defdelegate rating(assigns), to: PetalComponents.Rating
defdelegate slide_over(assigns), to: PetalComponents.SlideOver
defdelegate table(assigns), to: PetalComponents.Table
defdelegate td(assigns), to: PetalComponents.Table
defdelegate tr(assigns), to: PetalComponents.Table
defdelegate th(assigns), to: PetalComponents.Table
defdelegate tabs(assigns), to: PetalComponents.Tabs
defdelegate h1(assigns), to: PetalComponents.Typography
defdelegate h2(assigns), to: PetalComponents.Typography
defdelegate h3(assigns), to: PetalComponents.Typography
defdelegate h4(assigns), to: PetalComponents.Typography
defdelegate h5(assigns), to: PetalComponents.Typography
defdelegate p(assigns), to: PetalComponents.Typography
defdelegate prose(assigns), to: PetalComponents.Typography
defdelegate ol(assigns), to: PetalComponents.Typography
defdelegate ul(assigns), to: PetalComponents.Typography
end
end
end

Then call components like so:

<.form for={@form} phx-submit="on_submit" phx-target={@myself}>
<PC.field field={@form[:title]} />
<PC.field field={@form[:description]} />
<PC.button label="Save" />
</.form>

Starting from a fresh project

If you’re starting a brand new Phoenix project, two options:

  • The free Phoenix boilerplate ships with petal_components pre-installed.
  • Petal Pro is the paid SaaS boilerplate built on petal_components — auth, multi-tenancy, Stripe billing, background jobs, the full set.

Or just install in your existing project with the two-step way above.