Modal dialogs, drawers, dropdowns, command palette, popovers, and tooltips — all with focus management and keyboard navigation.
Table of Contents
dialog
Modal dialog with focus trap, Escape to close, and scroll lock.
Hook: PhiaDialog
Sub-components: dialog_trigger/1, dialog_content/1, dialog_header/1, dialog_title/1, dialog_description/1, dialog_footer/1, dialog_close/1
<%!-- Dialog with trigger button --%>
<.dialog id="edit-user">
<:trigger>
<.button variant="outline">Edit profile</.button>
</:trigger>
<.dialog_content>
<.dialog_header>
<.dialog_title>Edit profile</.dialog_title>
<.dialog_description>
Update your name and avatar. Click Save when done.
</.dialog_description>
</.dialog_header>
<div class="py-4 space-y-4">
<.phia_input field={@form[:name]} label="Full name" />
<.phia_input field={@form[:bio]} label="Bio" />
</div>
<.dialog_footer>
<.dialog_close>
<.button variant="outline">Cancel</.button>
</.dialog_close>
<.button phx-click="save-profile">Save changes</.button>
</.dialog_footer>
</.dialog_content>
</.dialog>
<%!-- Server-controlled open state --%>
<.dialog id="import-data" open={@show_import}>
<.dialog_content>
<.dialog_header>
<.dialog_title>Import Data</.dialog_title>
</.dialog_header>
<.file_upload id="import-upload" phx-change="validate-file" phx-drop-target={@uploads.data.ref}>
<.live_file_input upload={@uploads.data} class="hidden" />
<div class="text-center p-8">
<.icon name="upload" class="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p class="text-sm text-muted-foreground">Drop CSV here or click to browse</p>
</div>
</.file_upload>
<.dialog_footer>
<.button phx-click="close-import" variant="outline">Cancel</.button>
<.button phx-click="start-import" disabled={@uploads.data.entries == []}>Import</.button>
</.dialog_footer>
</.dialog_content>
</.dialog>alert_dialog
Confirmation dialog. role="alertdialog". See feedback/alert_dialog for full details.
drawer
Side or bottom slide-in panel. 4 directions, focus trap, backdrop-click close.
Hook: PhiaDrawer
Sub-components: drawer_content/1, drawer_header/1, drawer_footer/1, drawer_close/1
Directions: right (default), left, top, bottom
<%!-- Right-side filter drawer --%>
<.button variant="outline" phx-click="open-filters">
<.icon name="filter" size="sm" /> Filters
</.button>
<.drawer_content id="filters-drawer" open={@filters_open} direction="right">
<.drawer_header>
<h2 class="text-lg font-semibold">Filters</h2>
<p class="text-sm text-muted-foreground">Narrow down your results</p>
</.drawer_header>
<.drawer_close phx-click="close-filters" />
<div class="px-6 py-4 space-y-6 flex-1 overflow-y-auto">
<.filter_builder
fields={@filter_fields}
rules={@filter_rules}
on_add="add_rule"
on_remove="remove_rule"
on_change="update_rule"
/>
</div>
<.drawer_footer>
<.button class="flex-1" phx-click="apply-filters">Apply filters</.button>
<.button variant="outline" phx-click="reset-filters">Reset</.button>
</.drawer_footer>
</.drawer_content>
<%!-- Bottom sheet for mobile --%>
<.drawer_content id="mobile-actions" open={@actions_open} direction="bottom">
<.drawer_header>
<h2 class="text-base font-semibold">Actions for <%= @selected_item.name %></h2>
</.drawer_header>
<div class="px-4 pb-6 space-y-2">
<.button variant="outline" class="w-full" phx-click="edit-item">
<.icon name="pencil" size="sm" /> Edit
</.button>
<.button variant="destructive" class="w-full" phx-click="delete-item">
<.icon name="trash" size="sm" /> Delete
</.button>
</div>
</.drawer_content>sheet
Side-panel modal (drawer-style but modal). 4 sides, 5 sizes.
Hook: PhiaDialog
Sub-components: sheet_trigger/1, sheet_content/1, sheet_header/1, sheet_title/1, sheet_description/1, sheet_footer/1, sheet_close/1
Sizes: sm, default, lg, xl, full
<.sheet id="cart">
<:trigger>
<.button variant="outline" size="icon">
<.icon name="shopping-cart" />
</.button>
</:trigger>
<.sheet_content side="right" size="lg">
<.sheet_header>
<.sheet_title>Shopping cart</.sheet_title>
<.sheet_description><%= @cart_count %> items</.sheet_description>
</.sheet_header>
<div class="flex-1 overflow-y-auto py-4 space-y-4">
<div :for={item <- @cart_items} class="flex items-center gap-4">
<img src={item.image} class="h-16 w-16 rounded object-cover" />
<div class="flex-1">
<p class="font-medium text-sm"><%= item.name %></p>
<p class="text-sm text-muted-foreground"><%= item.variant %></p>
</div>
<div class="text-right">
<p class="font-medium">$<%= item.price %></p>
<.button variant="ghost" size="sm" phx-click="remove-from-cart" phx-value-id={item.id}>
Remove
</.button>
</div>
</div>
</div>
<.sheet_footer class="flex-col gap-2">
<div class="flex justify-between text-sm font-medium">
<span>Total</span>
<span>$<%= @cart_total %></span>
</div>
<.button class="w-full" phx-click="checkout">Checkout</.button>
</.sheet_footer>
</.sheet_content>
</.sheet>dropdown_menu
Trigger + dropdown menu with smart flip, click-outside, and arrow-key navigation.
Hook: PhiaDropdownMenu
Sub-components: dropdown_menu_trigger/1, dropdown_menu_content/1, dropdown_menu_item/1, dropdown_menu_separator/1, dropdown_menu_label/1, dropdown_menu_checkbox_item/1, dropdown_menu_radio_group/1, dropdown_menu_radio_item/1
<%!-- Row actions in a table --%>
<.dropdown_menu id={"actions-#{@item.id}"}>
<:trigger>
<.button variant="ghost" size="icon">
<.icon name="more-horizontal" />
</.button>
</:trigger>
<:content>
<.dropdown_menu_item phx-click="edit" phx-value-id={@item.id}>
<.icon name="pencil" size="sm" /> Edit
</.dropdown_menu_item>
<.dropdown_menu_item phx-click="duplicate" phx-value-id={@item.id}>
<.icon name="copy" size="sm" /> Duplicate
</.dropdown_menu_item>
<.dropdown_menu_separator />
<.dropdown_menu_item
class="text-destructive focus:text-destructive"
phx-click="delete"
phx-value-id={@item.id}
>
<.icon name="trash" size="sm" /> Delete
</.dropdown_menu_item>
</:content>
</.dropdown_menu>
<%!-- View options with checkboxes --%>
<.dropdown_menu id="view-options">
<:trigger>
<.button variant="outline" size="sm">
<.icon name="settings-2" size="sm" /> View
</.button>
</:trigger>
<:content>
<.dropdown_menu_label>Show columns</.dropdown_menu_label>
<.dropdown_menu_separator />
<.dropdown_menu_checkbox_item
:for={col <- @columns}
checked={col.visible}
phx-click="toggle-column"
phx-value-key={col.key}
>
<%= col.label %>
</.dropdown_menu_checkbox_item>
</:content>
</.dropdown_menu>context_menu
Right-click context menu with smart positioning and WAI-ARIA role="menu".
Hook: PhiaContextMenu
Sub-components: context_menu_trigger/1, context_menu_content/1, context_menu_item/1, context_menu_separator/1, context_menu_label/1
<%!-- File manager context menu --%>
<.context_menu id={"file-ctx-#{@file.id}"}>
<:trigger>
<div class="p-4 rounded border cursor-pointer hover:bg-muted" phx-value-id={@file.id}>
<.icon name="file-text" class="h-8 w-8 text-muted-foreground" />
<p class="text-xs mt-2 text-center truncate"><%= @file.name %></p>
</div>
</:trigger>
<:content>
<.context_menu_item phx-click="open-file" phx-value-id={@file.id}>Open</.context_menu_item>
<.context_menu_item phx-click="rename-file" phx-value-id={@file.id}>Rename</.context_menu_item>
<.context_menu_item phx-click="download-file" phx-value-id={@file.id}>Download</.context_menu_item>
<.context_menu_separator />
<.context_menu_item class="text-destructive" phx-click="delete-file" phx-value-id={@file.id}>
Move to trash
</.context_menu_item>
</:content>
</.context_menu>popover
Click-open floating panel with focus trap and click-outside close.
Hook: PhiaPopover
Sub-components: popover_trigger/1, popover_content/1
<%!-- Date picker in a popover --%>
<.popover id="date-picker-pop">
<:trigger>
<.button variant="outline" class="w-48 justify-start text-left">
<.icon name="calendar" size="sm" class="mr-2" />
<%= if @selected_date, do: Date.to_string(@selected_date), else: "Pick a date" %>
</.button>
</:trigger>
<:content>
<.calendar value={@selected_date} on_change="set-date" />
</:content>
</.popover>
<%!-- Color picker popover --%>
<.popover id="color-pop">
<:trigger>
<button class="h-8 w-8 rounded border shadow" style={"background:#{@color}"} />
</:trigger>
<:content>
<.color_picker id="cp" value={@color} on_change="set-color" />
</:content>
</.popover>tooltip
Hover + focus tooltip. 4 positions, smart flip.
Hook: PhiaTooltip
Sub-components: tooltip_trigger/1, tooltip_content/1
Positions: top (default), right, bottom, left
<%!-- Icon button with tooltip --%>
<.tooltip>
<:trigger>
<.button variant="ghost" size="icon">
<.icon name="info" />
</.button>
</:trigger>
<:content>
This is your unique API key. Do not share it.
</:content>
</.tooltip>
<%!-- Disabled button explanation --%>
<.tooltip>
<:trigger>
<span class="inline-block">
<.button disabled>Publish</.button>
</span>
</:trigger>
<:content>
Complete all required fields to publish.
</:content>
</.tooltip>
<%!-- Position variants --%>
<.tooltip position="right">
<:trigger><.button variant="ghost" size="icon"><.icon name="settings" /></.button></:trigger>
<:content>Settings</:content>
</.tooltip>hover_card
Hover preview card with role="tooltip". Pure HEEx, no hook.
Sub-components: hover_card_trigger/1, hover_card_content/1
<%!-- User profile preview --%>
<.hover_card>
<:trigger>
<a href={~p"/users/#{@user.id}"} class="font-medium underline-offset-4 hover:underline">
@<%= @user.username %>
</a>
</:trigger>
<:content class="w-72">
<div class="flex items-start gap-3">
<.avatar size="lg">
<.avatar_image src={@user.avatar_url} />
<.avatar_fallback name={@user.name} />
</.avatar>
<div>
<p class="font-semibold"><%= @user.name %></p>
<p class="text-sm text-muted-foreground">@<%= @user.username %></p>
<p class="text-sm mt-2"><%= @user.bio %></p>
<div class="flex gap-4 mt-3 text-sm text-muted-foreground">
<span><strong class="text-foreground"><%= @user.following_count %></strong> Following</span>
<span><strong class="text-foreground"><%= @user.followers_count %></strong> Followers</span>
</div>
</div>
</div>
</:content>
</.hover_card>command
Ctrl+K command palette with server-side filtering and arrow-key navigation.
Hook: PhiaCommand
Sub-components: command_dialog/1, command_input/1, command_list/1, command_group/1, command_item/1, command_separator/1, command_empty/1
<%!-- Global command palette --%>
<.command_dialog id="command-palette" open={@command_open}>
<.command_input
value={@command_query}
placeholder="Search commands…"
on_change="search-commands"
/>
<.command_list>
<.command_empty>No results found.</.command_empty>
<.command_group label="Navigation">
<.command_item :for={item <- @nav_results}
on_click="navigate-to"
value={item.href}
>
<.icon name={item.icon} size="sm" class="mr-2" />
<%= item.label %>
<kbd class="ml-auto text-xs text-muted-foreground"><%= item.shortcut %></kbd>
</.command_item>
</.command_group>
<.command_separator />
<.command_group label="Recent documents">
<.command_item :for={doc <- @recent_docs}
on_click="open-document"
value={doc.id}
>
<.icon name="file-text" size="sm" class="mr-2" />
<%= doc.title %>
</.command_item>
</.command_group>
</.command_list>
</.command_dialog>def mount(_params, _session, socket) do
{:ok, assign(socket,
command_open: false,
command_query: "",
nav_results: nav_commands(),
recent_docs: []
)}
end
def handle_event("search-commands", %{"query" => q}, socket) do
results = filter_commands(nav_commands(), q)
{:noreply, assign(socket, command_query: q, nav_results: results)}
end
def handle_event("navigate-to", %{"value" => href}, socket) do
{:noreply, assign(socket, command_open: false) |> push_navigate(to: href)}
end
# Keyboard shortcut: Ctrl+K opens the palette
# The PhiaCommand hook handles this automatically.