Components Sidebar

Petal Pro is the full SaaS app this is built for

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

The app-shell navigation rail - icon-rail and offcanvas collapse, a mobile sheet, and a footer that holds the account menu. State is one data attribute, so LiveView, JS commands and your own code all agree.
App shell

The whole anatomy: a shell, a branded header, two labelled groups, icons and a badge, and a footer. The rail toggle in the header collapses it to icons - no server round trip.

Dashboard
Your page content lives here. It is marked inert while the mobile sheet is open.
heex
<.sidebar_shell
for="sb-shell"
class="h-[30rem] min-h-0 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800"
>
<:sidebar>
<.sidebar_nav id="sb-shell" label="Main">
<:header>
<.icon name="hero-cube" class="w-5 h-5 shrink-0 text-primary-500" />
<span class="pc-sidebar__brand">Acme Inc</span>
<.sidebar_trigger for="sb-shell" class="ml-auto" />
</:header>
<.sidebar_group label="Workspace">
<.sidebar_item label="Dashboard" path="#" link_type="a" icon="hero-home" active />
<.sidebar_item label="Inbox" path="#" link_type="a" icon="hero-inbox" badge="12" />
<.sidebar_item label="Customers" path="#" link_type="a" icon="hero-users" />
</.sidebar_group>
<.sidebar_group label="Account">
<.sidebar_item label="Settings" icon="hero-cog-6-tooth" open>
<.sidebar_item label="Profile" path="#" link_type="a" />
<.sidebar_item label="Billing" path="#" link_type="a" />
</.sidebar_item>
<.sidebar_item label="Team" path="#" link_type="a" icon="hero-user-group" />
</.sidebar_group>
<:footer>
<.sidebar_item
label="Sign out"
path="#"
link_type="a"
icon="hero-arrow-left-start-on-rectangle"
/>
</:footer>
</.sidebar_nav>
</:sidebar>
<header class="flex items-center flex-none gap-3 px-4 border-b border-gray-200 h-14 dark:border-gray-800">
<.sidebar_trigger for="sb-shell" target="mobile" />
<span class="text-sm font-semibold">Dashboard</span>
</header>
<div class="p-4 text-sm text-gray-500 dark:text-gray-400">
Your page content lives here. It is marked inert while the mobile sheet is open.
</div>
</.sidebar_shell>
Collapsed rail

collapsible="icon" with collapsed set - the rail the app renders on first paint. Labels drop to screen-reader-only text and the title attribute carries them on hover, so nothing is lost.

Hit the toggle at the bottom of the rail to expand it back out.
heex
<.sidebar_shell
for="sb-rail"
class="h-[22rem] min-h-0 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800"
>
<:sidebar>
<.sidebar_nav id="sb-rail" label="Compact" collapsible="icon" collapsed>
<:header>
<.icon name="hero-cube" class="w-5 h-5 shrink-0 text-primary-500" />
<span class="pc-sidebar__brand">Acme</span>
</:header>
<.sidebar_group label="Workspace">
<.sidebar_item label="Dashboard" path="#" link_type="a" icon="hero-home" active />
<.sidebar_item label="Inbox" path="#" link_type="a" icon="hero-inbox" badge="12" />
<.sidebar_item label="Customers" path="#" link_type="a" icon="hero-users" />
</.sidebar_group>
<:footer>
<.sidebar_trigger for="sb-rail" class="mx-auto" />
</:footer>
</.sidebar_nav>
</:sidebar>
<div class="p-4 text-sm text-gray-500 dark:text-gray-400">
Hit the toggle at the bottom of the rail to expand it back out.
</div>
</.sidebar_shell>
Collapsible groups

Groups follow the WAI-ARIA disclosure pattern: the label becomes a button carrying aria-expanded, and the run of items it controls is hidden or shown by CSS.

Click a group label to collapse it. collapsible="none" pins this sidebar open at every width.
heex
<.sidebar_shell
for="sb-groups"
class="h-[22rem] min-h-0 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800"
>
<:sidebar>
<.sidebar_nav id="sb-groups" label="Docs" collapsible="none">
<.sidebar_group label="Getting started" collapsible open>
<.sidebar_item label="Installation" path="#" link_type="a" active />
<.sidebar_item label="Theming" path="#" link_type="a" />
</.sidebar_group>
<.sidebar_group label="Components" collapsible open={false}>
<.sidebar_item label="Button" path="#" link_type="a" />
<.sidebar_item label="Modal" path="#" link_type="a" />
<.sidebar_item label="Table" path="#" link_type="a" />
</.sidebar_group>
<.sidebar_group>
<.sidebar_item label="Changelog" path="#" link_type="a" icon="hero-sparkles" />
</.sidebar_group>
</.sidebar_nav>
</:sidebar>
<div class="p-4 text-sm text-gray-500 dark:text-gray-400">
Click a group label to collapse it. collapsible="none" pins this sidebar open at every width.
</div>
</.sidebar_shell>
Right-hand inspector

side="right" plus an offcanvas mode gives you an inspector panel. Two sidebars in one shell just need different ids - their state never crosses.

Canvas
heex
<.sidebar_shell
for="sb-inspector"
class="h-[20rem] min-h-0 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800"
>
<:sidebar>
<.sidebar_nav id="sb-inspector" label="Inspector" side="right" collapsible="offcanvas">
<:header>
<span class="pc-sidebar__brand">Properties</span>
<.sidebar_trigger for="sb-inspector" class="ml-auto" label="Hide inspector" />
</:header>
<.sidebar_group label="Layer">
<.sidebar_item label="Fill" path="#" link_type="a" icon="hero-swatch" active />
<.sidebar_item label="Stroke" path="#" link_type="a" icon="hero-pencil" />
<.sidebar_item label="Effects" path="#" link_type="a" icon="hero-sparkles" />
</.sidebar_group>
</.sidebar_nav>
</:sidebar>
<div class="flex items-center gap-3 p-4">
<.sidebar_trigger for="sb-inspector" label="Show inspector" />
<span class="text-sm text-gray-500 dark:text-gray-400">Canvas</span>
</div>
</.sidebar_shell>
Properties
<.sidebar_shell>
Attribute Type Default Description
class any nil CSS class for the shell wrapper
for* string id of the `sidebar_nav/1` this shell wraps. The content region is rendered as `<for>-main` so the trigger can mark it inert while the mobile sheet is open
rest global
:inner_block slot slot everything else in the shell - topbar, page content. Marked inert while the sheet is open
:sidebar slot slot the `sidebar_nav/1` itself
<.sidebar_nav>
Attribute Type Default Description
class any nil CSS class for the sidebar
collapsed boolean false initial collapsed state, rendered server-side so the first paint is never wrong
collapsible string "icon" icon collapses to a rail of icons, offcanvas hides it completely, none pins it open at every width
one of: "icon", "offcanvas", "none"
id* string unique id - `sidebar_trigger/1` targets this
label string "Sidebar" accessible name for the nav landmark (`aria-label`)
on_close {:struct, Phoenix.LiveView.JS} %Phoenix.LiveView.JS{ops: []} additional JS commands to run when the mobile sheet closes (LiveView.JS only)
rest global
side string "left" which edge the sidebar sits on. Two sidebars in one shell just need different ids
one of: "left", "right"
:footer slot slot pinned bottom area, typically a user menu. A dropdown here should align with the rail: align="start" on a left sidebar, align="end" on a right one, so the panel grows into the page instead of off the viewport
:header slot slot pinned top area - logo, workspace switcher
:inner_block slot slot `sidebar_group/1` and `sidebar_item/1` content
<.sidebar_group>
Attribute Type Default Description
class any nil CSS class for the group
collapsible boolean false turns the label into a disclosure button (WAI-ARIA disclosure pattern)
id string nil defaults to a slug of the label
label string nil group heading. Omit for an unlabelled run of items
on_toggle {:struct, Phoenix.LiveView.JS} %Phoenix.LiveView.JS{ops: []} additional JS commands to run when the group is toggled (LiveView.JS only)
open boolean true initial state when collapsible
rest global
:inner_block slot slot `sidebar_item/1` children
<.sidebar_item>
Attribute Type Default Description
active boolean false marks the current page. Emits `aria-current="page"`. Your app decides, never the component
badge string nil trailing badge text, e.g. an unread count
class any nil CSS class for the item
icon any nil a heroicon name ("hero-home"), a function component, or a raw SVG string - the house icon convention
id string nil defaults to a slug of the label
label* string the item text. Kept for screen readers when collapsed
link_type string "live_redirect" how the item navigates, matching `PetalComponents.Link.a/1`
one of: "live_redirect", "live_patch", "a", "button"
on_toggle {:struct, Phoenix.LiveView.JS} %Phoenix.LiveView.JS{ops: []} additional JS commands to run when the sub-menu is toggled (LiveView.JS only)
open boolean false initial state of the sub-menu, when it has one
path string nil where the item links to. Omit when it has sub-items
rest global
:inner_block slot slot nested `sidebar_item/1` children. Turns the item into a disclosure
<.sidebar_trigger>
Attribute Type Default Description
class any nil CSS class for the trigger
for* string id of the `sidebar_nav/1` this button controls
id string nil a `target="mobile"` trigger defaults to `"<for>-trigger"`, which is where focus returns when the sheet closes. Collapse triggers get no id by default, so a shell can carry both without colliding
label string "Toggle sidebar" accessible name for the button
on_click {:struct, Phoenix.LiveView.JS} %Phoenix.LiveView.JS{ops: []} additional JS commands to run on click - e.g. `JS.push("toggle_sidebar")` to mirror the state server-side
rest global
target string "collapse" collapse toggles the desktop rail (hidden below md); mobile opens the off-canvas sheet (hidden from md up)
one of: "collapse", "mobile"
:inner_block slot slot custom button content. Defaults to a hamburger/panel icon