Fluxon.Components.Sheet
(Fluxon v3.0.0)
Renders a sliding panel anchored to one edge of the viewport. Sheets are well suited to navigation drawers, filter panels, detail views, and mobile-style action sheets, with a backdrop, focus management, and client and server-side open/close control.
Sheets share their underlying hook with Fluxon.Components.Modal and
participate in the same stacking system, so a sheet and a modal can open on
top of each other and the topmost dialog always owns focus and keyboard input.
Sheet vs Modal
Sheets and modals expose the same attributes and lifecycle events. They differ
in visual presentation: a sheet slides in from a screen edge and spans the
full width or height of the viewport, while a modal centers (or aligns) over
the page. Reach for Fluxon.Components.Modal when the content benefits from
a centered overlay; reach for the sheet when you want a drawer, side panel,
or bottom action sheet.
Usage
Open a sheet from a button and let users dismiss it with the close button, the Escape key, or a click on the backdrop:
<.button phx-click={Fluxon.open_dialog("filters-sheet")}>Filters</.button>
<.sheet id="filters-sheet">
<h3 class="text-lg font-semibold mb-4">Filters</h3>
<!-- Filter content -->
<.button phx-click={Fluxon.close_dialog("filters-sheet")}>Apply</.button>
</.sheet>Both Fluxon.open_dialog/1 and Fluxon.close_dialog/1 are pure
Phoenix.LiveView.JS commands, so no round trip to the server is needed.
Open on page load
Pass open to render the sheet already visible on mount:
<.sheet id="welcome-sheet" open>
Welcome aboard.
</.sheet>Initial focus
When a sheet opens, focus moves onto the panel itself rather than the first
control inside it, so no focus ring appears until the user presses Tab. To
land focus on a specific control instead (a search field, the first form
input, a primary button), add autofocus to that element:
<.sheet id="search-sheet" placement="top">
<.input autofocus type="search" placeholder="Search..." />
</.sheet>While the sheet is open, Tab and Shift+Tab cycle only through the controls inside it. Closing the sheet returns focus to whatever was focused before it opened.
Placement
The placement attribute controls which edge the sheet slides in from. The
matching slide animation is selected automatically.
| Placement | Slide direction | Use Case |
|---|---|---|
left (default) | Slides in from the left edge | Primary navigation drawers, app menus, tree-style file pickers |
right | Slides in from the right edge | Filters, detail views, contextual settings, edit panels |
top | Slides down from the top edge | Notifications, command palettes, search overlays |
bottom | Slides up from the bottom edge | Mobile-style action sheets, picker dialogs, confirmation panels |
Sheets anchored to left or right span the full viewport height. Sheets
anchored to top or bottom span the full viewport width (the content area
receives data-full-width). Use class to set the perpendicular dimension,
for example class="w-80" on a left sheet or class="h-96" on a bottom sheet.
<!-- Left drawer for navigation -->
<.sheet id="nav-sheet" placement="left" class="w-80">
<nav class="space-y-2"><!-- ... --></nav>
</.sheet>
<!-- Right sheet for filters -->
<.sheet id="details-sheet" placement="right" class="w-96">
<!-- Details content -->
</.sheet>
<!-- Bottom action sheet -->
<.sheet id="actions-sheet" placement="bottom" class="h-96">
<!-- Action items -->
</.sheet>Server-Side Control
There are two ways to drive a sheet from the server.
Bind open to an assign
The simplest pattern: tie visibility to a boolean assign and let
phx-click events flip it.
<.button phx-click="show-sheet">Open Filters</.button>
<.sheet id="filters-sheet" open={@show_filters}>
<!-- ... -->
</.sheet>Push from a LiveView callback
Use Fluxon.open_dialog/2 and Fluxon.close_dialog/2 to open or close a
sheet by id from any handle_event/3 or handle_info/2:
def handle_event("apply_filters", params, socket) do
# ...apply filter logic...
{:noreply, Fluxon.close_dialog(socket, "filters-sheet")}
endThis pairs well with prevent_closing: the sheet will only close in
response to a server-pushed event, never to ESC, backdrop click, or the
built-in close button.
Client and server state can desync
When open is bound to an assign (open={@show_filters}), client-side
dismissals (Escape, backdrop click, close button) close the sheet
immediately in the browser but do not update the server assign. The next
time the LiveView re-renders, the assign still says true and the sheet
reopens.
Two ways to keep things in sync:
1. Use on_close to push back to the server (recommended)
<.sheet id="filters-sheet" open={@show_filters} on_close={JS.push("hide_filters")}>Any close path (Escape, backdrop, close button, or a server-pushed close)
fires on_close, so the server can flip its assign back to false.
2. Use prevent_closing for server-only control
<.sheet id="filters-sheet" open={@show_filters} prevent_closing>
<.button phx-click="hide_filters">Close</.button>
</.sheet>All client-side dismissals are disabled, so the assign and the sheet's visibility cannot drift. Best suited to critical workflows (multi-step forms, destructive confirmations) where the server must validate state before allowing the sheet to close.
Dynamic Content
A single sheet can display different content depending on what the user clicked. Open the sheet immediately on the client, then push an event to load the right data:
<.sheet id="filter-sheet" on_close={JS.push("reset_filters")}>
<div :if={@filter_context}>
<h3 class="text-lg font-semibold mb-4">{@filter_context.title}</h3>
<div :for={filter <- @filter_context.filters} class="mb-4">
<.input
type="checkbox"
label={filter.label}
checked={filter.selected}
phx-click="toggle_filter"
phx-value-id={filter.id}
/>
</div>
</div>
</.sheet>
<.button phx-click={Fluxon.open_dialog("filter-sheet") |> JS.push("load_user_filters")}>
User Filters
</.button>
<.button phx-click={Fluxon.open_dialog("filter-sheet") |> JS.push("load_date_filters")}>
Date Filters
</.button>Three things are happening here:
Fluxon.open_dialog/1opens the sheet on the client without a round trip.JS.push/2notifies the server which dataset to load into@filter_context.on_closeclears@filter_contextwhen the sheet closes, so the next opening starts from a clean slate.
In production you usually want a loading state and a fixed size to avoid the content shifting as new data streams in:
<.sheet id="filter-sheet" class="w-80" on_close={JS.push("reset_filters")}>
<div class="min-h-[300px] relative">
<div :if={!@filter_context} class="absolute inset-0 flex items-center justify-center">
<.loading />
</div>
<div :if={@filter_context}>
<h3 class="text-lg font-semibold mb-4">{@filter_context.title}</h3>
<!-- Filter content -->
</div>
</div>
</.sheet>Sizing and Scrolling
By default a left or right sheet fills the viewport height; a top or bottom
sheet fills the viewport width. Use class to constrain the other axis. The
content area scrolls when its content overflows.
For sheets with a header or footer that should remain visible while the body scrolls, structure the inner block as a flex column:
<.sheet id="settings-sheet" placement="right" class="w-96 flex flex-col">
<header class="border-b border-zinc-100 -mx-6 px-6 pb-4 mb-4 shrink-0">
<h2 class="text-lg font-semibold">Settings</h2>
</header>
<div class="flex-1 overflow-y-auto space-y-4">
<div :for={setting <- @settings} class="py-2 border-b border-zinc-100 last:border-0">
<div class="font-medium">{setting.name}</div>
<div class="text-sm text-zinc-600">{setting.description}</div>
</div>
</div>
<footer class="border-t border-zinc-100 -mx-6 px-6 pt-4 mt-4 shrink-0 flex justify-end">
<.button phx-click={Fluxon.close_dialog("settings-sheet")}>Close</.button>
</footer>
</.sheet>Stacking
Sheets share their stacking system with modals: opening a second dialog (sheet or modal) places it on top of the first, and only the topmost dialog receives focus, keyboard input, and outside-click handling. Background dialogs remain visible for context. Closing the topmost dialog automatically returns focus to the previous one.
<.sheet id="user-edit" placement="right" class="w-96">
<h3 class="text-lg font-semibold">Edit User</h3>
<!-- ...form fields... -->
<.button
color="red"
phx-click={Fluxon.open_dialog("confirm-discard")}
>
Discard changes
</.button>
</.sheet>
<.modal id="confirm-discard">
<h3 class="text-lg font-semibold">Discard unsaved changes?</h3>
<div class="flex justify-end gap-3 mt-4">
<.button phx-click={Fluxon.close_dialog("confirm-discard")}>Keep editing</.button>
<.button
color="red"
phx-click={
Fluxon.close_dialog("confirm-discard")
|> Fluxon.close_dialog("user-edit")
}
>
Discard
</.button>
</div>
</.modal>Examples
Filter panel with apply and reset
<.button phx-click={Fluxon.open_dialog("filters-sheet")}>Filters</.button>
<.sheet id="filters-sheet" placement="right" class="w-96">
<.form for={@filters_form} phx-change="filters_changed" phx-submit="apply_filters">
<h3 class="text-lg font-semibold mb-4">Filters</h3>
<.input field={@filters_form[:status]} type="select" label="Status" options={~w(any open closed)} />
<.input field={@filters_form[:owner]} type="select" label="Owner" options={@owners} />
<div class="flex justify-end gap-3 mt-6">
<.button type="button" variant="ghost" phx-click="reset_filters">Reset</.button>
<.button type="submit">Apply</.button>
</div>
</.form>
</.sheet>Mobile-style action sheet
<.sheet id="row-actions" placement="bottom" class="h-auto rounded-t-xl">
<ul class="divide-y divide-zinc-100">
<li>
<button class="w-full text-left py-3" phx-click="duplicate_row" phx-value-id={@row.id}>
Duplicate
</button>
</li>
<li>
<button class="w-full text-left py-3" phx-click="archive_row" phx-value-id={@row.id}>
Archive
</button>
</li>
<li>
<button
class="w-full text-left py-3 text-red-600"
phx-click="delete_row"
phx-value-id={@row.id}
phx-confirm="Delete this row? This cannot be undone."
>
Delete
</button>
</li>
</ul>
</.sheet>Server-driven sheet with state sync
<.sheet
id="user-details"
placement="right"
class="w-[28rem]"
open={@selected_user != nil}
on_close={JS.push("clear_selected_user")}
>
<div :if={@selected_user}>
<h3 class="text-lg font-semibold">{@selected_user.name}</h3>
<p class="text-sm text-zinc-600">{@selected_user.email}</p>
</div>
</.sheet>def handle_event("select_user", %{"id" => id}, socket) do
{:noreply, assign(socket, :selected_user, Accounts.get_user!(id))}
end
def handle_event("clear_selected_user", _params, socket) do
{:noreply, assign(socket, :selected_user, nil)}
endForm sheet that closes on save
<.sheet id="new-user-sheet" placement="right" class="w-96">
<.form for={@form} phx-submit="save_user">
<header class="mb-4">
<h2 class="text-lg font-semibold">New User</h2>
<p class="text-sm text-zinc-600">Create a new user account.</p>
</header>
<.input field={@form[:name]} label="Name" />
<.input field={@form[:email]} type="email" label="Email" />
<.input field={@form[:role]} type="select" label="Role" options={["admin", "user"]} />
<div class="flex justify-end gap-3 mt-6">
<.button type="button" phx-click={Fluxon.close_dialog("new-user-sheet")}>Cancel</.button>
<.button type="submit" phx-disable-with="Creating...">Create user</.button>
</div>
</.form>
</.sheet>def handle_event("save_user", %{"user" => user_params}, socket) do
case Accounts.create_user(user_params) do
{:ok, _user} ->
{:noreply,
socket
|> put_flash(:info, "User created successfully")
|> Fluxon.close_dialog("new-user-sheet")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
endCritical workflow with no client-side dismissal
<.sheet id="onboarding-sheet" placement="right" class="w-[32rem]" open={@onboarding_open} prevent_closing>
<h3 class="text-lg font-semibold">Finish setup</h3>
<p class="text-sm text-zinc-600 mb-4">Complete your profile to continue.</p>
<.live_component module={OnboardingFormComponent} id="onboarding" current_user={@current_user} />
</.sheet>Combined with prevent_closing, the sheet has no close button and ignores
Escape and backdrop clicks. Only a server-side Fluxon.close_dialog/2 can
close it, which guarantees the workflow is committed before the user moves on.
Summary
Components
Renders a sliding panel anchored to one edge of the viewport.
Components
Renders a sliding panel anchored to one edge of the viewport.
Use a sheet for content that benefits from edge anchoring, such as navigation drawers, filter panels, detail views, and mobile-style action sheets. The component handles its own enter and exit animation, scroll lock, focus trap, and stacking with other open sheets and modals.
Open and close a sheet from the client with Fluxon.open_dialog/1 and
Fluxon.close_dialog/1, or from the server with Fluxon.open_dialog/2 and
Fluxon.close_dialog/2. Bind open to an assign for declarative control,
and use on_close (or prevent_closing) to keep server state in sync with
client-side dismissals.
Attributes
id(:string) (required) - Unique identifier for the sheet. Used as the target forFluxon.open_dialog/1,Fluxon.close_dialog/1, and the corresponding socket-based helpers. Must be unique across all dialogs on the page.open(:boolean) - Controls whether the sheet is open. Defaults tofalse. Bind this to a server assign for declarative control (open={@show_filters}), or set it totrueto render the sheet visible on initial mount. When the value flips, the sheet runs its slide-in or slide-out animation.Defaults to
false.on_close(Phoenix.LiveView.JS) -Phoenix.LiveView.JScommands to run after the sheet has finished closing. Fires for every dismissal path: Escape, backdrop click, close button, server-pushed close, andFluxon.close_dialog/1.Defaults to
%Phoenix.LiveView.JS{ops: []}.on_open(Phoenix.LiveView.JS) -Phoenix.LiveView.JScommands to run after the sheet has finished opening and its slide-in animation completes. Fires for every open path: the client helpers, a server-pushed open, and a boundopenassign flipping totrue. Useful for lazily loading content, focusing a custom control, or pushing an analytics event.Defaults to
%Phoenix.LiveView.JS{ops: []}.class(:any) - Extra classes merged onto the sheet's content container. The most common use is sizing:class="w-80"orclass="w-96"for left or right sheets, andclass="h-72"orclass="h-auto"for top or bottom sheets. Also useful for adding rounded corners on edge-anchored sheets (rounded-t-xlon a bottom sheet) or laying out fixed headers and footers (flex flex-col).Defaults to
nil.close_on_esc(:boolean) - Controls whether pressing Escape closes the sheet. Defaults totrue. Set tofalsefor full-screen experiences where Escape might conflict with nested controls (for example, a sheet hosting a code editor or video player). Ignored whenprevent_closingistrue.Defaults to
true.close_on_outside_click(:boolean) - Controls whether clicking the backdrop closes the sheet. Defaults totrue. Set tofalseto require an explicit close action (close button, custom button, or server-driven close). The sheet uses two-phase tracking so a drag that starts inside the content and ends on the backdrop will not close the sheet. Ignored whenprevent_closingistrue.Defaults to
true.prevent_closing(:boolean) - Whentrue, disables every client-side dismissal path: the Escape key, backdrop clicks, and the built-in close button (which is no longer rendered). The sheet can only be closed byFluxon.close_dialog/2from the server or by flipping a boundopenassign. Useful for multi-step workflows, mandatory onboarding, or destructive confirmations where the server must validate state before allowing the sheet to close.Defaults to
false.hide_close_button(:boolean) - Hides the built-in close button in the top-right corner without affecting Escape or backdrop dismissal. Defaults tofalse. Use this when you want to provide your own close control inside the sheet body (for example, a "Cancel" button in a footer) while keeping standard keyboard and click-out dismissals working.Defaults to
false.backdrop_class(:string) - Extra classes merged onto the backdrop overlay. The default backdrop is a semi-transparent black layer; override it for a lighter scrim (bg-black/30), a tinted overlay, or a fully transparent backdrop (bg-transparent) when stacking sheets over already-dimmed content.Defaults to
nil.placement(:string) - Controls which edge of the viewport the sheet anchors to and slides in from. The matching slide animation is selected automatically.left(default): Slides in from the left edge, full-height. Use for primary navigation drawers, tree-style file pickers, or app menus.right: Slides in from the right edge, full-height. Use for filters, detail views, contextual settings, and edit panels.top: Slides down from the top edge, full-width. Use for notifications, command palettes, or search overlays.bottom: Slides up from the bottom edge, full-width. Use for mobile-style action sheets, picker dialogs, and confirmation panels.
Defaults to
"left". Must be one of"left","right","top", or"bottom".
Slots
inner_block(required) - The body of the sheet. Accepts arbitrary HEEx, including forms,Phoenix.LiveComponents, and other Fluxon components. Common patterns include a header, a scrollable content region, and a footer with action buttons; structure these with a flex column on the sheet'sclassfor a fixed header and footer with a scrolling middle.