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.

PlacementSlide directionUse Case
left (default)Slides in from the left edgePrimary navigation drawers, app menus, tree-style file pickers
rightSlides in from the right edgeFilters, detail views, contextual settings, edit panels
topSlides down from the top edgeNotifications, command palettes, search overlays
bottomSlides up from the bottom edgeMobile-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")}
end

This 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:

  1. Fluxon.open_dialog/1 opens the sheet on the client without a round trip.
  2. JS.push/2 notifies the server which dataset to load into @filter_context.
  3. on_close clears @filter_context when 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)}
end

Form 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
end

Critical 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

sheet(assigns)

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 for Fluxon.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 to false. Bind this to a server assign for declarative control (open={@show_filters}), or set it to true to 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.JS commands to run after the sheet has finished closing. Fires for every dismissal path: Escape, backdrop click, close button, server-pushed close, and Fluxon.close_dialog/1.

    Defaults to %Phoenix.LiveView.JS{ops: []}.

  • on_open (Phoenix.LiveView.JS) - Phoenix.LiveView.JS commands 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 bound open assign flipping to true. 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" or class="w-96" for left or right sheets, and class="h-72" or class="h-auto" for top or bottom sheets. Also useful for adding rounded corners on edge-anchored sheets (rounded-t-xl on 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 to true. Set to false for full-screen experiences where Escape might conflict with nested controls (for example, a sheet hosting a code editor or video player). Ignored when prevent_closing is true.

    Defaults to true.

  • close_on_outside_click (:boolean) - Controls whether clicking the backdrop closes the sheet. Defaults to true. Set to false to 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 when prevent_closing is true.

    Defaults to true.

  • prevent_closing (:boolean) - When true, 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 by Fluxon.close_dialog/2 from the server or by flipping a bound open assign. 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 to false. 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's class for a fixed header and footer with a scrolling middle.