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)
- Manual install
- Additional setup (Alpine JS, VSCode snippets, namespacing, Core Components conflicts)
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
defp deps do
[
{:petal_components, "~> 3.2"},
]
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.
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
Alpine JS (for interactive components)
Components like Dropdown and Accordion default to Alpine JS. You can opt out and use Phoenix.LiveView.JS instead (only works in live views) via the js_lib attribute on each component.
Add Alpine to your root layout:
<head>
<!-- For accordion -->
<script defer src="https://unpkg.com/@alpinejs/collapse@3.x.x/dist/cdn.min.js">
</script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js">
</script>
</head>
VSCode snippets
- VSCode extension — 65+ snippets covering every component
- Full snippet reference
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 :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.