Components
Sidebar
Sidebar
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.
<.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.
<.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.
<.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.
<.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 |