Components Install

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)

bash
          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:

bash
          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

elixir
          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.

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

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

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:

elixir
          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.