Components Layouts

Petal Pro is the full SaaS app this is built for

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

Petal Pro

Layouts

A responsive layout system for your app. All navigation lives in the sidebar — no desktop header bar.

How to access

Membership
$299
-> 
See plans

This comes with Petal Pro. Purchase a membership to get access to this package. It can be used with any Phoenix project. Post-expiration, you'll retain access but won't be eligible for updates from newer versions.

The sidebar layout puts all navigation in a left-hand panel — no desktop header bar. This is the default layout in Petal Pro.

  • Desktop: sidebar always visible, collapses to icon-only rail if collapsible is set
  • Mobile: sticky top bar with a hamburger button slides the sidebar in as an overlay
  • Structure: logo at the top, scrollable menu in the middle, optional bottom_section_content at the bottom (for user profile, settings, etc.)

Quick example

Collapsible sidebar

Add the collapsible attr to let users collapse the sidebar to a narrow icon-only rail. Collapse state persists across page loads via Alpine.js $persist() in localStorage.

<.sidebar_layout collapsible current_page={@current_page} main_menu_items={@menu_items}>
  ...
</.sidebar_layout>

Also available:

  • collapsed_only — sidebar is always collapsed, no toggle button shown
  • default_collapsed — sidebar starts collapsed on first visit (before localStorage is set)

Collapsible requires the Alpine Persist plugin. Petal Pro includes it by default.

Bottom section

Use the :bottom_section_content slot for anything that should anchor to the bottom of the sidebar — user profile, settings link, auth buttons. It renders above a border separator.

<.sidebar_layout ...>
  <:bottom_section_content>
    <.user_dropdown_menu
      user_menu_items={@user_menu_items}
      avatar_src={@avatar_src}
      current_user_name={@current_user.name}
    />
  </:bottom_section_content>
  ...
</.sidebar_layout>
elixir
            # <.sidebar_layout>
  attr :collapsible, :boolean, default: false, doc: "Sidebar can be collapsed to icon-only rail. Requires the Alpine Persist plugin."
  attr :collapsed_only, :boolean, default: false, doc: "Sidebar is always collapsed — no toggle shown."
  attr :default_collapsed, :boolean, default: false, doc: "Sidebar starts collapsed on first visit. Requires :collapsible."
  attr :current_page, :atom, required: true, doc: "Highlights the active item in the menu."
  attr :main_menu_items, :list, default: [], doc: "Items rendered in the sidebar menu."
  attr :sidebar_title, :string, default: nil, doc: "Optional title shown above menu items."
  attr :home_path, :string, default: "/", doc: "Logo link destination."
  attr :sidebar_width_class, :string, default: "w-72", doc: "Sidebar width on mobile (when open)."
  attr :sidebar_lg_width_class, :string, default: "lg:w-72", doc: "Sidebar width on desktop. Must include the lg: prefix."
  attr :sidebar_bg_class, :string, default: "bg-white dark:bg-gray-900"
  attr :sidebar_border_class, :string, default: "border-gray-200 dark:border-gray-700"
  slot :inner_block, required: true, doc: "Page content."
  slot :adjacent, doc: "Rendered adjacent to the sidebar rail — useful for flyout menus at the layout stacking context."
  slot :bottom_section_content, doc: "Anchored to the bottom of the sidebar, above a border. Use for user profile, auth buttons, etc."
  slot :logo, doc: "Your logo. Wrapped in a link to home_path."
  slot :logo_icon, doc: "Compact logo icon shown when sidebar is collapsed and on the mobile sticky bar."
  slot :sidebar, doc: "Additional sidebar content rendered below menu items."

        

Stacked layout

The stacked layout has a top navbar with the menu across the header. Good for apps with fewer nav items or a more traditional web app feel.

View demo

Get up and running

The same menu item data structure works for both layouts, making it easy to switch.

Stacked layout properties

elixir
            # <.stacked_layout>
  attr :current_page, :atom, required: true, doc: "Highlights the active menu item."
  attr :main_menu_items, :list, default: [], doc: "Items displayed in the header nav."
  attr :user_menu_items, :list, default: [], doc: "Items in the user dropdown (top right)."
  attr :avatar_src, :string, default: nil, doc: "User avatar. Falls back to initials if nil."
  attr :current_user_name, :string, default: nil, doc: "Displayed in the user dropdown."
  attr :home_path, :string, default: "/", doc: "Logo link destination."
  attr :container_max_width, :string, default: "lg", values: ["sm", "md", "lg", "xl", "full"], doc: "Max width of the header container — match your content container."
  attr :hide_active_menu_item_border, :boolean, default: false
  attr :main_bg_class, :string, default: "bg-white dark:bg-gray-900"
  attr :header_bg_class, :string, default: "bg-white dark:bg-gray-900"
  attr :header_border_class, :string, default: "border-gray-200 dark:border-gray-700"
  attr :sticky, :boolean, default: false, doc: "Makes the navbar stick to the top."
  slot :inner_block, required: true
  slot :top_right, doc: "Right side of the header — color scheme switcher, notification bell, etc."
  slot :logo, doc: "Your logo. Wrapped in a link to home_path."

        

Installation

Define your layout in app.html.heex (or a new file like sidebar.html.heex). Since you’ll want access to @socket, put your layout in the app layout rather than the root layout.

If you create a new layout file, wire it up as a live layout.

Here is an example:

elixir
          <.flash_group flash={@flash} />
<.sidebar_layout
  current_page={@current_page}
  main_menu_items={[
    %{
      name: :dashboard,
      label: "Dashboard",
      icon: "hero-home",
      path: ~p"/"
    },
  ]}
>
  <:logo>
    LOGO GOES HERE
  </:logo>
  <:bottom_section_content>
    <%!-- User profile, auth buttons, etc. --%>
  </:bottom_section_content>

  <%= @inner_content %>
</.sidebar_layout>

        

Set current_page in every live view:

def mount(_params, _session, socket) do
  socket = assign(socket,
    current_page: :dashboard,
    page_title: "Dashboard"
  )
  {:ok, socket}
end

For controllers:

def index(conn, _params) do
  conn
  |> assign(:current_page, :dashboard)
  |> assign(:page_title, "Dashboard")
  |> render("index.html")
end

main_menu_items is a list of maps. For the sidebar layout the items appear in the sidebar panel; for stacked they appear in the header.

Each item has:

  • name — atom, used to highlight the current page
  • label — display text
  • icon — a Heroicon name string, raw HTML string, or function component (&my_icon/1)
  • path — navigation target
  • patch_group — optional module atom; items sharing the same value use patch navigation between them
[
  %{name: :dashboard, label: "Dashboard", icon: "hero-home", path: ~p"/"},
  %{name: :settings, label: "Settings", icon: "hero-cog-6-tooth", path: ~p"/settings"}
]

Nested menu items

Provide a menu_items key to create a collapsible dropdown group (Alpine JS required):

[
  %{
    name: :company,
    label: "Company",
    icon: "hero-building-office",
    menu_items: [
      %{name: :reports, label: "Reports", icon: "hero-chart-pie", path: ~p"/reports"},
      %{name: :company_settings, label: "Settings", icon: "hero-cog-6-tooth", path: ~p"/company/settings"}
    ]
  }
]

Grouped menu items

Wrap items in a group map with a title key to render labeled sections in the sidebar:

[
  %{
    title: "Main",
    menu_items: [
      %{name: :dashboard, label: "Dashboard", icon: "hero-home", path: ~p"/"}
    ]
  },
  %{
    title: "Settings",
    menu_items: [
      %{name: :settings, label: "Account", icon: "hero-user", path: ~p"/settings"}
    ]
  }
]

User menu items

user_menu_items follows the same structure and populates the dropdown attached to the user avatar. Nesting and grouping are not supported there.

Patching between menu items

If multiple menu items link to the same live view, set patch_group to the same value on each. Navigation between them will use patch instead of navigate, avoiding a full remount:

[
  %{name: :settings, label: "Settings", icon: "hero-cog", path: ~p"/settings", patch_group: MyAppWeb.SettingsLive},
  %{name: :settings_profile, label: "Profile", icon: "hero-user", path: ~p"/settings/profile", patch_group: MyAppWeb.SettingsLive},
  %{name: :settings_billing, label: "Billing", icon: "hero-credit-card", path: ~p"/settings/billing", patch_group: MyAppWeb.SettingsLive}
]

Handling signed in users

For the sidebar layout, put the user profile in the :bottom_section_content slot:

<.sidebar_layout ...>
  <:bottom_section_content>
    <.user_dropdown_menu
      user_menu_items={@user_menu_items}
      avatar_src={@current_user.avatar}
      current_user_name={@current_user.name}
    />
  </:bottom_section_content>
  ...
</.sidebar_layout>

For the stacked layout, pass user attrs directly:

<.stacked_layout
  avatar_src={@current_user.avatar}
  current_user_name={@current_user.name}
  user_menu_items={@user_menu_items}
  ...
/>

Custom menu items (sidebar layout only)

Sometimes you need a menu item that does something other than navigate — like toggling a flyout panel. Use this structure to render any component as a menu item:

# :main_menu_items
[
  %{
    custom_assigns: %{id: "id-you-apply-in-func"},
    custom_component: &render_me/1
  },
  # live components also work
  %{
    custom_assigns: %{id: "required-id-for-lc", current_user: @current_user},
    custom_component: MyApp.SomeLiveComponent
  }
]

If you’re using the collapsible sidebar, handle the isCollapsed Alpine JS state in your custom component markup.