Full-height application shell with a responsive sidebar and topbar.
This is the outermost layout primitive for admin panels, dashboards, and SaaS applications. It uses CSS Grid on desktop and collapses to a flex column with a JS-toggled overlay drawer on mobile — no Alpine.js, no custom JavaScript required.
Layout structure
┌──────────────────────────────────────────┐
│ topbar (col-span-full, h-14) │
├─────────────────┬────────────────────────┤
│ │ │
│ sidebar │ main content │
│ (240 px) │ (overflow-y-auto) │
│ │ │
└─────────────────┴────────────────────────┘Desktop: grid grid-cols-[240px_1fr] h-dvh overflow-hidden
Mobile: flex flex-col; the <aside> becomes a fixed overlay drawer.
CSS theme tokens
All background colors reference CSS custom properties:
--background— main content area and topbar--sidebar-background— sidebar background--sidebar-foreground— sidebar text--sidebar-border— sidebar border color
These tokens are defined in priv/static/theme.css and overridden per
preset by phia-themes.css (generated via mix phia.theme install).
Switching themes or toggling dark mode automatically updates colors without
any prop changes to the shell.
Sub-components
| Component | Purpose |
|---|---|
shell/1 | Outer CSS Grid wrapper; receives named slots |
sidebar/1 | Collapsible 240 px aside; supports :default/:dark variants |
sidebar_item/1 | Navigation link with active highlight, icon, and badge |
sidebar_section/1 | Groups nav items under an uppercase section label |
topbar/1 | Sticky top bar (h-14, border-b); standalone or via slot |
mobile_sidebar_toggle/1 | Hamburger button that calls JS.toggle/1; hidden on md+ |
Complete dashboard example
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, socket}
end
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end
def render(assigns) do
~H"""
<.shell>
<:topbar>
<%!-- Hamburger is hidden on md+ via Tailwind; shown on mobile --%>
<.mobile_sidebar_toggle />
<span class="ml-2 font-semibold text-foreground">Acme Corp</span>
<div class="ml-auto flex items-center gap-3">
<.dark_mode_toggle id="dm-toggle" />
<span class="text-sm text-muted-foreground">Jane Smith</span>
</div>
</:topbar>
<:sidebar>
<.sidebar>
<:brand>
<span class="text-lg font-bold text-foreground">⬡ Acme</span>
</:brand>
<:nav_items>
<.sidebar_section label="Main Menu">
<.sidebar_item href="/dashboard" active={@live_action == :index}>
<:icon><.icon name="layout-dashboard" /></:icon>
Dashboard
</.sidebar_item>
<.sidebar_item href="/analytics" active={@live_action == :analytics}>
<:icon><.icon name="bar-chart-2" /></:icon>
Analytics
</.sidebar_item>
<.sidebar_item
href="/inbox"
active={@live_action == :inbox}
badge={@unread_count}
>
<:icon><.icon name="inbox" /></:icon>
Inbox
</.sidebar_item>
</.sidebar_section>
<.sidebar_section label="Management">
<.sidebar_item href="/customers" active={@live_action == :customers}>
<:icon><.icon name="users" /></:icon>
Customers
</.sidebar_item>
<.sidebar_item href="/reports" active={@live_action == :reports}>
<:icon><.icon name="file-text" /></:icon>
Reports
</.sidebar_item>
</.sidebar_section>
</:nav_items>
<:footer_items>
<.sidebar_item href="/settings" active={@live_action == :settings}>
<:icon><.icon name="settings" /></:icon>
Settings
</.sidebar_item>
<.sidebar_item href="/help">
<:icon><.icon name="help-circle" /></:icon>
Help & Support
</.sidebar_item>
</:footer_items>
</.sidebar>
</:sidebar>
<%!-- Main content — scrollable, occupies the remaining grid column --%>
<main class="overflow-y-auto p-6">
<h1 class="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p class="mt-1 text-muted-foreground">Welcome back, Jane.</p>
</main>
</.shell>
"""
end
endDark sidebar variant
The :dark variant forces a dark enterprise look regardless of the current
color mode, which is common for tools like Vercel, Linear, or Notion:
<.sidebar variant={:dark}>
...
</.sidebar>Mobile behavior
On small screens the sidebar is hidden by default. Clicking the
mobile_sidebar_toggle/1 button calls Phoenix.LiveView.JS.toggle/1 which
slides the sidebar in/out with a 300 ms CSS transition. The transition is
declarative — no hook or WebSocket round-trip required.
Summary
Functions
Hamburger/menu button that toggles the mobile sidebar via Phoenix.LiveView.JS.
Full-height application shell using CSS Grid on desktop.
Responsive sidebar with brand area, scrollable nav, and pinned footer.
A navigation link inside the sidebar.
Groups sidebar navigation items under an optional section label.
Standalone sticky top navigation bar (h-14, border-b, bg-background).
Functions
Hamburger/menu button that toggles the mobile sidebar via Phoenix.LiveView.JS.
The button is hidden on md: and wider (md:hidden) so it does not
appear on desktop where the sidebar is always visible. Place it in the
:topbar slot of shell/1 so it appears at the left edge of the topbar
on mobile.
The toggle uses JS.toggle/1 with transition-transform duration-300 ease-in-out classes: the sidebar slides in from the left on open and out
on close. No server round-trip is required.
Example
<:topbar>
<%!-- Only visible on mobile (< md breakpoint) --%>
<.mobile_sidebar_toggle />
<span class="ml-2 font-semibold">Acme</span>
</:topbar>Attributes
target(:string) - CSS selector for the element toggled byJS.toggle/1. Defaults to#mobile-sidebar, which matches the<aside>rendered byshell/1. Override this if you use a custom sidebar element ID.Defaults to
"#mobile-sidebar".class(:string) - Additional CSS classes. Defaults tonil.Global attributes are accepted.
Full-height application shell using CSS Grid on desktop.
The outer wrapper grows to h-dvh (dynamic viewport height, which handles
the iOS Safari address bar correctly) and clips overflow so that only the
main content column scrolls — the sidebar and topbar stay fixed.
On mobile (below md: breakpoint) the layout switches to flex flex-col.
The sidebar becomes position: fixed and is toggled via JS.toggle/1.
Slots
:topbar— optional full-width header row (spans both grid columns):sidebar— required aside column (240 px wide on desktop, drawer on mobile):inner_block— main scrollable content area
Example
<.shell>
<:topbar>...</:topbar>
<:sidebar><.sidebar>...</.sidebar></:sidebar>
<main class="p-6">Page content</main>
</.shell>Attributes
class(:string) - Additional CSS classes for the outermost wrapper div. Defaults tonil.safe_area(:boolean) - Whentrue, addspb-[env(safe-area-inset-bottom)]to the root element to account for the iOS Safari home indicator and notch on modern devices.Defaults to
false.Global attributes are accepted. HTML attributes forwarded to the wrapper div (e.g. id, data-* attrs).
Slots
sidebar(required) - Sidebar content (required). On desktop this renders as a 240 px fixed aside column. On mobile it is hidden by default and shown as an overlay drawer when the user tapsmobile_sidebar_toggle/1.topbar- Top navigation bar spanning the full width above the grid (optional). When omitted the grid starts at the very top of the viewport. Placemobile_sidebar_toggle/1and user actions here.inner_block(required) - Main content area. Receivesflex-1andoverflow-y-auto— wrap your page content in a<main>or<div>here with appropriate padding.
Responsive sidebar with brand area, scrollable nav, and pinned footer.
The sidebar is always 240 px wide (set on the CSS Grid column). On desktop
it is a static grid cell with flex flex-col and border-r. On mobile the
parent shell/1 component manages its visibility as an overlay.
The layout is a vertical flex container divided into three parts:
┌──────────────────────────┐ ← h-14 brand area (shrink-0, border-b)
│ brand slot │
├──────────────────────────┤
│ │ ← flex-1, overflow-y-auto
│ nav_items slot │
│ │
├──────────────────────────┤ ← shrink-0, border-t
│ footer_items slot │
└──────────────────────────┘Example
<.sidebar variant={:default}>
<:brand>
<img src="/logo.svg" alt="Acme" class="h-6 w-auto" />
</:brand>
<:nav_items>
<.sidebar_item href="/dashboard" active>Dashboard</.sidebar_item>
</:nav_items>
<:footer_items>
<.sidebar_item href="/settings">Settings</.sidebar_item>
</:footer_items>
</.sidebar>Attributes
id(:string) - Element ID used bymobile_sidebar_toggle/1'sJS.toggle/1call. Keep the default unless you render multiple shells on the same page.Defaults to
"sidebar-drawer".collapsed(:boolean) - Whentrue, translates the sidebar off-screen via-translate-x-full. Useful for programmatic collapse without the mobile overlay pattern.Defaults to
false.class(:string) - Additional CSS classes. Defaults tonil.variant(:atom) - Visual variant for the sidebar background.:default— uses--sidebar-backgroundand--sidebar-foregroundtokens, which respect the current color theme and dark mode.:dark— forces a dark background regardless of color mode. Appliesdark bg-sidebar-background text-sidebar-foregroundclasses directly, producing the "always dark" look used by tools like Vercel or Linear.
Defaults to
:default. Must be one of:default, or:dark.Global attributes are accepted. HTML attributes forwarded to the aside element.
Slots
brand- Logo or application name area rendered at the top of the sidebar inside ah-14row that aligns with the topbar height. Typically holds a wordmark, icon-plus-name combo, or workspace switcher.nav_items- Primary navigation items (the main middle section of the sidebar). This slot is placed in anoverflow-y-auto<nav>element so that long navigation lists scroll independently of the footer. Usesidebar_section/1andsidebar_item/1inside this slot.footer_items- Secondary items anchored to the bottom of the sidebar (above the fold). Typically holds Settings and Help links. Rendered in ashrink-0div with a top border separating it from the primary nav.inner_block- Fallback slot for fully custom sidebar content when the named slots (:brand,:nav_items,:footer_items) do not provide enough structure. Only rendered when:nav_itemsis empty.
A navigation link inside the sidebar.
Renders a full-width anchor element with:
- Active state highlighting via
bg-accent text-accent-foreground - Optional leading icon in its own
shrink-0container - Optional trailing badge count (circular pill with
bg-primary) - Keyboard focus outline inherited from the global focus ring tokens
Example
<%!-- Basic item --%>
<.sidebar_item href="/dashboard" active={@live_action == :index}>
Dashboard
</.sidebar_item>
<%!-- Item with icon and notification badge --%>
<.sidebar_item href="/inbox" active={@live_action == :inbox} badge={@unread}>
<:icon><.icon name="inbox" /></:icon>
Inbox
</.sidebar_item>Attributes
href(:string) - Navigation href for the anchor element. Defaults to"#".active(:boolean) - Highlights this item as the currently active route. Addsbg-accent text-accent-foregroundwhentrue. Typically derived from@live_action == :route_namein your LiveView.Defaults to
false.badge(:integer) - Optional notification badge count displayed on the right side of the item. Passnil(the default) to hide the badge entirely. Common for unread message counts, pending task counts, etc.Defaults to
nil.class(:string) - Additional CSS classes for the anchor element. Defaults tonil.Global attributes are accepted. HTML attributes forwarded to the anchor element.
Slots
icon- Optional icon displayed before the label. Use<.icon name="...">inside this slot. The icon is wrapped inshrink-0so it does not compress when the label is long.inner_block(required) - The text label for this navigation item.
Groups sidebar navigation items under an optional section label.
Use multiple sidebar_section/1 components inside the :nav_items slot of
sidebar/1 to create a visually separated, labelled hierarchy of links.
Section labels use text-xs uppercase tracking-wider for a compact
enterprise-style appearance. Items within the section are spaced with
space-y-0.5 for tight, scannable lists.
Example
<.sidebar_section label="Analytics">
<.sidebar_item href="/revenue" active={@live_action == :revenue}>
Revenue
</.sidebar_item>
<.sidebar_item href="/retention">Retention</.sidebar_item>
</.sidebar_section>
<.sidebar_section label="Settings">
<.sidebar_item href="/team">Team</.sidebar_item>
<.sidebar_item href="/billing">Billing</.sidebar_item>
</.sidebar_section>Attributes
label(:string) - Section heading displayed in small uppercase muted text above the items. Passnilto render the items without any heading (useful for the first section where a heading is redundant).Defaults to
nil.class(:string) - Additional CSS classes for the section wrapper. Defaults tonil.Global attributes are accepted.
Slots
inner_block(required) - sidebar_item/1 components to group under this section.
Standalone sticky top navigation bar (h-14, border-b, bg-background).
This component is useful when building a topbar-only layout (no sidebar), or
when you need to render a topbar outside of a shell/1. When using shell/1,
prefer the :topbar slot directly — it renders the same markup internally.
Example
<%!-- Standalone topbar for a non-sidebar layout --%>
<.topbar>
<a href="/" class="font-semibold text-foreground">Acme</a>
<nav class="ml-6 flex gap-4 text-sm text-muted-foreground">
<a href="/docs">Docs</a>
<a href="/pricing">Pricing</a>
</nav>
<div class="ml-auto">
<.dark_mode_toggle id="topbar-dm" />
</div>
</.topbar>Attributes
class(:string) - Additional CSS classes for the header element. Defaults tonil.- Global attributes are accepted.
Slots
inner_block(required) - Topbar content — brand, search, actions, user avatar.