Drawer component — a sliding panel that enters from any edge of the screen.
A drawer provides access to supplementary content or actions without leaving
the current page. Unlike Sheet, the Drawer is designed for content-heavy
panels and uses its own dedicated PhiaDrawer JavaScript hook (rather than
sharing PhiaDialog) with CSS transform animations for the enter/exit motion.
When to use Drawer vs Sheet
| Aspect | Drawer | Sheet |
|---|---|---|
| JS Hook | PhiaDrawer (dedicated) | PhiaDialog (shared) |
| Panel anatomy | Simple (header/footer/close) | Rich (title/description/close) |
| Trigger | drawer_trigger/1 button | Controlled by :open assign |
| Animation | CSS transform slide | CSS transform slide |
| Use case | Side nav, detail views, step forms | Edit forms, filter panels |
Use Drawer when you want a dedicated trigger button colocated with the
content and a simpler, content-driven panel. Use Sheet when state from
the server controls open/close or when you need the richer ARIA sub-components.
Hook Registration
Copy the hook via mix phia.add drawer, then register it in app.js:
# assets/js/app.js
import PhiaDrawer from "./hooks/drawer"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { PhiaDrawer }
})Sub-components
| Function | Purpose |
|---|---|
drawer/1 | Root container — wraps trigger and content |
drawer_trigger/1 | Button that opens the drawer |
drawer_content/1 | Sliding panel + backdrop — the hook mount point |
drawer_header/1 | Title and description layout container |
drawer_footer/1 | Action row at the bottom |
drawer_close/1 | × close button in the top-right corner |
Directions
| Value | Slides from | Default panel dimensions |
|---|---|---|
"bottom" | bottom edge | Full width, max 85vh, rounded top corners |
"top" | top edge | Full width, max 85vh, rounded bottom corners |
"left" | left edge | 75% width, max sm, full height |
"right" | right edge | 75% width, max sm, full height |
Example — Settings Drawer (right)
The canonical drawer pattern: a button triggers a right-side panel.
<.drawer id="settings-drawer">
<.drawer_trigger drawer_id="settings-drawer">
<.button>
<.icon name="settings" class="mr-2" />
Settings
</.button>
</.drawer_trigger>
<.drawer_content id="settings-drawer-content" direction="right">
<.drawer_header>
<h2 id="settings-drawer-content-title" class="text-lg font-semibold">
Settings
</h2>
<p class="text-sm text-muted-foreground">
Manage your account preferences.
</p>
</.drawer_header>
<.drawer_close />
<div class="p-6 space-y-4">
<.input name="display_name" label="Display name" value={@user.name} />
<.select name="timezone" label="Timezone" options={@timezones} />
<.switch name="email_notifications" label="Email notifications" checked={@user.email_notifs} />
</div>
<.drawer_footer>
<button phx-click="cancel_settings">Cancel</button>
<button phx-click="save_settings" class="...">Save</.button>
</.drawer_footer>
</.drawer_content>
</.drawer>Example — Mobile Bottom Sheet
A bottom drawer is the mobile-native pattern for action sheets:
<.drawer id="actions-drawer">
<.drawer_trigger drawer_id="actions-drawer" class="md:hidden">
<.button variant="outline">Actions</.button>
</.drawer_trigger>
<.drawer_content id="actions-drawer-content" direction="bottom">
<.drawer_header>
<h2 id="actions-drawer-content-title" class="text-sm font-medium">
Item Actions
</h2>
</.drawer_header>
<.drawer_close />
<div class="p-4 pb-8 space-y-2">
<button phx-click="edit_item" class="w-full text-left px-4 py-3 rounded-md hover:bg-muted">
Edit
</button>
<button phx-click="duplicate_item" class="w-full text-left px-4 py-3 rounded-md hover:bg-muted">
Duplicate
</button>
<button phx-click="delete_item" class="w-full text-left px-4 py-3 rounded-md text-destructive hover:bg-destructive/10">
Delete
</button>
</div>
</.drawer_content>
</.drawer>Example — Step-by-Step Form (left drawer)
Multi-step forms work well in a left drawer that feels like an overlay wizard:
<.drawer id="onboarding">
<.drawer_trigger drawer_id="onboarding">
<.button>Start Setup</.button>
</.drawer_trigger>
<.drawer_content id="onboarding-content" direction="left" open={@show_onboarding}>
<.drawer_header>
<h2 id="onboarding-content-title" class="text-lg font-semibold">
Setup Wizard — Step {@step} of 3
</h2>
</.drawer_header>
<.drawer_close />
<div class="p-6">
<%= render_step(@step) %>
</div>
<.drawer_footer>
<button phx-click="prev_step" disabled={@step == 1}>Back</button>
<button phx-click="next_step">
{if @step == 3, do: "Finish", else: "Next"}
</button>
</.drawer_footer>
</.drawer_content>
</.drawer>Server-Controlled Open State
Pass open={@show_drawer} to drawer_content/1 to control visibility from
the server (e.g. after a navigation event or background task completion):
<.drawer_content id="notif-content" direction="right" open={@show_notifications}>
...
</.drawer_content>
# Push from LiveView
{:noreply, assign(socket, :show_notifications, true)}Hook Behaviour
The PhiaDrawer hook (mounted on drawer_content/1) handles:
- CSS
transformanimation (slide in/out) on open/close - Focus trap — Tab / Shift+Tab cycle within the open panel
Escapekey closes the panel and returns focus to the trigger- Backdrop click closes the panel
- Focus return — restores focus to
drawer_trigger/1(or last focused element) when the drawer closes
Accessibility
drawer_content/1hasrole="dialog"andaria-modal="true"aria-labelledby="{id}-title"is auto-set — give your title heading the id"{drawer-content-id}-title"for the link to resolvedrawer_close/1hasaria-label="Close drawer"for screen reader users- Keyboard: focus automatically moves to the first focusable element on open
Summary
Functions
Root container for the Drawer. Wraps both drawer_trigger/1 and
drawer_content/1. Provides the relative positioning context and a
shared ID namespace.
The × close button rendered in the top-right corner of the drawer panel.
The drawer surface — renders the semi-transparent backdrop and the sliding
panel. This is the PhiaDrawer hook mount point.
Action row at the bottom of the drawer.
Layout container for the drawer title and optional description.
Wrapper that opens the drawer when any element inside it is clicked.
Functions
Root container for the Drawer. Wraps both drawer_trigger/1 and
drawer_content/1. Provides the relative positioning context and a
shared ID namespace.
The root is purely a layout wrapper — no hook attaches here. The hook
mounts on drawer_content/1.
Attributes
id(:string) (required) - Unique drawer ID used as a namespace for child IDs.class(:string) - Additional CSS classes. Defaults tonil.- Global attributes are accepted. Extra HTML attributes forwarded to the root
<div>.
Slots
inner_block(required) -drawer_trigger/1anddrawer_content/1.
The × close button rendered in the top-right corner of the drawer panel.
The PhiaDrawer JS hook listens for clicks on data-drawer-close and:
- Reverses the CSS transform animation (slides the panel out)
- Adds the
hiddenclass to the root container after the animation - Returns focus to the
drawer_trigger/1that opened the drawer
The opacity-70 default with hover:opacity-100 keeps the button
present but unobtrusive, becoming fully visible on hover.
Attributes
class(:string) - Additional CSS classes. Defaults tonil.- Global attributes are accepted. Extra HTML attributes.
The drawer surface — renders the semi-transparent backdrop and the sliding
panel. This is the PhiaDrawer hook mount point.
The outer element is fixed inset-0 and starts hidden when open={false}.
It contains two children:
data-drawer-backdrop— the full-screen dark overlay (click to close)data-drawer-panel— the sliding panel itself
The hook applies CSS transform: translate* to the panel to create the
slide-in animation, then removes the hidden class from the container.
The aria-labelledby convention
The aria-labelledby is auto-set to "{id}-title". Give your title heading
this exact ID so the reference resolves:
<.drawer_content id="settings-content" direction="right">
<.drawer_header>
<h2 id="settings-content-title">Settings</h2>
</.drawer_header>
</.drawer_content>Attributes
id(:string) (required) - Content element ID — thePhiaDrawerhook mount point. By convention: "{drawer_id}-content".open(:boolean) - Whether the drawer is open on initial render. Whenfalse(default), the outer container has thehiddenclass. Passopen={@show_drawer}to control visibility from the server after the initial render.Defaults to
false.direction(:string) - Direction the drawer panel slides in from. Defaults to"bottom". Must be one of"bottom","top","left", or"right".class(:string) - Additional CSS classes for the sliding panel. Defaults tonil.Global attributes are accepted. Extra HTML attributes forwarded to the outer container
<div>.
Slots
inner_block(required) - Drawer panel content: header, body, footer, close.
Layout container for the drawer title and optional description.
Provides p-6 padding and vertical space-y-1.5 between title and
description. Always place at the top of the drawer content, before the
scrollable body.
Attributes
class(:string) - Additional CSS classes. Defaults tonil.- Global attributes are accepted. Extra HTML attributes.
Slots
inner_block(required) - Drawer heading and optional description paragraph.
Wrapper that opens the drawer when any element inside it is clicked.
Renders a <div> (not a <button>) so you can place any trigger content
inside — including a <.button> — without creating invalid nested-button
HTML. The PhiaDrawer hook intercepts clicks on [data-drawer-trigger]
anywhere in the document via event delegation, so the wrapper element type
does not need to be focusable itself.
Example
<.drawer_trigger drawer_id="settings-drawer">
<.button variant="outline">
<.icon name="settings" class="mr-2" />
Open Settings
</.button>
</.drawer_trigger>Attributes
drawer_id(:string) (required) - ID that matches the targetdrawer_content/1'sidattribute. The hook looks for[data-drawer-trigger]whose value equals thedrawer_content/1element'sid.class(:string) - Additional CSS classes. Defaults tonil.Global attributes are accepted. Extra HTML attributes forwarded to the wrapper
<div>.
Slots
inner_block(required) - Trigger content — any element, typically a<.button>.