PhiaUi.Components.Shell (phia_ui v0.1.16)

Copy Markdown View Source

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

ComponentPurpose
shell/1Outer CSS Grid wrapper; receives named slots
sidebar/1Collapsible 240 px aside; supports :default/:dark variants
sidebar_item/1Navigation link with active highlight, icon, and badge
sidebar_section/1Groups nav items under an uppercase section label
topbar/1Sticky top bar (h-14, border-b); standalone or via slot
mobile_sidebar_toggle/1Hamburger 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
end

Dark 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

mobile_sidebar_toggle(assigns)

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 by JS.toggle/1. Defaults to #mobile-sidebar, which matches the <aside> rendered by shell/1. Override this if you use a custom sidebar element ID.

    Defaults to "#mobile-sidebar".

  • class (:string) - Additional CSS classes. Defaults to nil.

  • Global attributes are accepted.

shell(assigns)

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 to nil.

  • safe_area (:boolean) - When true, adds pb-[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 taps mobile_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. Place mobile_sidebar_toggle/1 and user actions here.

  • inner_block (required) - Main content area. Receives flex-1 and overflow-y-auto — wrap your page content in a <main> or <div> here with appropriate padding.

sidebar(assigns)

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 by mobile_sidebar_toggle/1's JS.toggle/1 call. Keep the default unless you render multiple shells on the same page.

    Defaults to "sidebar-drawer".

  • collapsed (:boolean) - When true, 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 to nil.

  • variant (:atom) - Visual variant for the sidebar background.

    • :default — uses --sidebar-background and --sidebar-foreground tokens, which respect the current color theme and dark mode.
    • :dark — forces a dark background regardless of color mode. Applies dark bg-sidebar-background text-sidebar-foreground classes 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 a h-14 row 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 an overflow-y-auto <nav> element so that long navigation lists scroll independently of the footer. Use sidebar_section/1 and sidebar_item/1 inside this slot.

  • footer_items - Secondary items anchored to the bottom of the sidebar (above the fold). Typically holds Settings and Help links. Rendered in a shrink-0 div 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_items is empty.

topbar(assigns)

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 to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required) - Topbar content — brand, search, actions, user avatar.