Fluxon.Components.Popover (Fluxon v3.1.2)

A floating panel anchored to a trigger element that holds rich, interactive content.

Popover is the right choice when the floating content needs to be focusable, scrollable, or contain interactive elements like inputs, links, switches, or buttons. The panel positions itself relative to the trigger, flips and shifts to stay inside the viewport, animates in and out, and stays open while the user works inside it. It can be opened by click, hover, focus, or programmatic command.

Choosing between popover, tooltip, and dropdown

Reach for Fluxon.Components.Tooltip when the floating content is a short, non-interactive hint that should disappear as soon as the cursor leaves. Reach for Fluxon.Components.Dropdown when the content is a navigable menu of actions where arrow-key navigation, type-ahead, and roving focus matter. Reach for popover when the content is a self-contained panel of mixed interactive widgets that the user composes freely.

Usage

The simplest popover wraps a trigger and a :content slot. The first child of <.popover> is the trigger, and clicking it toggles the panel:

<.popover>
  <.button>Open</.button>

  <:content>
    <p class="text-sm">Anchored content here.</p>
  </:content>
</.popover>

Pass open_on_hover for a reveal-on-hover bubble, or open_on_focus to attach the panel to an input or other focusable trigger:

<.popover open_on_hover>
  <.icon name="hero-information-circle" class="text-foreground-softer" />

  <:content>
    <p class="text-sm">The invoice will be generated at the end of the month.</p>
  </:content>
</.popover>

Trigger Modes

The active trigger mode is inferred from the combination of attributes passed. Each mode controls when the panel opens, when it closes, and which interactions toggle it.

ModeWhen it activatesBest for
ClickDefault. Active when neither open_on_focus nor target is set.Settings menus, filter panels, action triggers.
Hoveropen_on_hover is set. Suppressed on touch devices.Information bubbles, link previews, contextual hints with rich content.
Focusopen_on_focus is set. Click toggling is disabled in this mode.Inputs that show suggestions, search fields, form helpers tied to focus.
Programmatictarget is set, or any popover controlled via Fluxon.open_popover/1.Multi-step flows, popovers anchored to elements outside the wrapper, server-driven open state.

Hover mode keeps the panel open while the cursor is over it, so links and buttons inside remain reachable. A short grace period after mouseleave lets the cursor cross the gap between trigger and panel without the popover closing.

Hover behavior on touch devices

Hover mode is suppressed when Fluxon detects a mobile or touch device, since touch devices have no real hover state and the panel would either never open or get stuck open after the first tap. Click mode takes over automatically, so a popover with open_on_hover still opens on tap.

Placement

placement hints at the preferred side relative to the trigger. The popover flips to the opposite side when there is not enough space and shifts along the cross axis to stay inside the viewport.

PlacementWhen to use
top, bottomAbove or below the trigger. Default sides for most popovers.
left, rightBeside the trigger. Use for triggers in dense rows or sidebars where vertical space is scarce.
*-startAligns the popover's leading edge with the trigger's leading edge. Use when the panel is wider than the trigger and should extend toward the end of the row.
*-endAligns the popover's trailing edge with the trigger's trailing edge. Common for buttons near the right edge of a toolbar so the panel does not overflow the viewport.
<.popover placement="bottom-end" class="w-64">
  <.button>Filters</.button>
  <:content>
    ...
  </:content>
</.popover>

Dismissal and Focus

A popover closes when the user presses Escape, clicks outside the panel, or (in hover and focus modes) moves the cursor or focus away. Escape dismissal is suppressed in focus mode so the key leaves the input naturally instead of closing the panel while the user is still working in the field.

Focus is not trapped inside the panel. Tab moves through the interactive content in order, and tabbing past the last element continues on to the next element on the page, so keyboard users are never stuck inside the popover.

Outside-clicks close the popover in every mode. In target mode, clicking the external trigger element does not count as an outside-click, so a single button can both open and close the popover.

Examples

Settings menu opened on click:

<.popover placement="bottom-end" class="w-64">
  <.button variant="ghost">
    <.icon name="hero-cog-6-tooth" /> Settings
  </.button>

  <:content>
    <div class="space-y-4">
      <div class="flex items-center justify-between">
        <span class="text-sm font-medium">Dark Mode</span>
        <.switch name="dark_mode" checked />
      </div>

      <div class="flex items-center justify-between">
        <span class="text-sm font-medium">Notifications</span>
        <.switch name="notifications" />
      </div>

      <.button size="sm" class="w-full" phx-click="reset_preferences">
        <.icon name="hero-arrow-path" class="icon" /> Reset preferences
      </.button>
    </div>
  </:content>
</.popover>

Search field with focus-driven suggestions:

<.popover open_on_focus placement="bottom-start" class="w-80">
  <.input
    type="search"
    name="query"
    placeholder="Search users..."
    phx-debounce="300"
    phx-change="search"
  />

  <:content>
    <div :if={@loading} class="p-4 flex justify-center">
      <.loading />
    </div>

    <button
      :for={user <- @users}
      type="button"
      phx-click="select_user"
      phx-value-id={user.id}
      class="w-full text-left p-2 hover:bg-zinc-50"
    >
      <div class="font-medium">{user.name}</div>
      <div class="text-sm text-foreground-softer">{user.email}</div>
    </button>
  </:content>
</.popover>

Inline help on a form field via the input's :inner_suffix slot:

<.input name="api_key" label="API Key" value={@api_key} class="font-mono">
  <:inner_suffix>
    <.popover open_on_hover placement="right">
      <.icon name="hero-question-mark-circle" class="text-foreground-softer" />

      <:content>
        <div class="max-w-xs space-y-2">
          <p class="text-sm font-medium">About API keys</p>
          <p class="text-sm text-foreground-softer">
            Your API key is used to authenticate requests. Keep it secure and never share
            it publicly.
          </p>
          <.link class="text-sm text-blue-600 hover:underline" navigate={~p"/docs/api-keys"}>
            Learn more about API keys
          </.link>
        </div>
      </:content>
    </.popover>
  </:inner_suffix>
</.input>

Filter panel anchored to the right edge of a toolbar:

<.popover placement="bottom-end" class="w-64">
  <.button variant="ghost">
    <.icon name="hero-adjustments-horizontal" class="icon" /> Table settings
  </.button>

  <:content>
    <h3 class="font-medium">Sort & view</h3>

    <div class="flex items-center gap-2 text-sm mt-3">
      <.icon name="hero-arrows-up-down" class="text-zinc-700 size-4" /> Sort by
      <.select
        native
        name="sort_by"
        value="Name"
        options={["Name", "Date", "Size", "Type", "Modified"]}
        size="sm"
        class="ml-auto py-1 shadow-none"
      />
    </div>

    <.separator class="my-4" />

    <h3 class="font-medium">Columns</h3>

    <div :for={col <- ~w(Name Date Size Type Modified)} class="flex items-center justify-between mt-3">
      <.label for={String.downcase(col)} class="text-zinc-700">{col}</.label>
      <.switch name={String.downcase(col)} id={String.downcase(col)} checked />
    </div>
  </:content>
</.popover>

Programmatic Control

Fluxon.open_popover/1 and Fluxon.close_popover/1 build Phoenix.LiveView.JS commands that toggle a popover by id from any handler:

<.button phx-click={Fluxon.open_popover("settings-popover")}>Open settings</.button>

<.popover id="settings-popover" placement="bottom-end">
  <.button variant="ghost">
    <.icon name="hero-cog-6-tooth" /> Settings
  </.button>

  <:content>
    <div class="p-2 space-y-2">
      <.button size="sm" class="w-full justify-start" navigate={~p"/profile"}>
        <.icon name="hero-user" class="icon" /> Profile
      </.button>
      <.button size="sm" class="w-full justify-start" navigate={~p"/security"}>
        <.icon name="hero-key" class="icon" /> Security
      </.button>
    </div>
  </:content>
</.popover>

Programmatic commands override the current state regardless of how the popover was originally opened. A popover with open_on_hover can still be closed from a button inside it by piping Fluxon.close_popover/1 from phx-click:

<.popover id="tooltip-popover" open_on_hover>
  <.icon name="hero-information-circle" class="text-foreground-softer" />

  <:content>
    <p class="text-sm">Helpful information tooltip</p>
    <.button size="xs" phx-click={Fluxon.close_popover("tooltip-popover")}>
      Close
    </.button>
  </:content>
</.popover>

Server-Side Control

The popover can also be driven entirely from the server, mirroring the Fluxon.Components.Modal API. Choose whichever of the approaches below fits the handler.

Declarative open attribute

Bind the popover's open state directly to a LiveView assign. Updating the assign opens or closes the popover with the enter/leave animation:

<.popover id="server-controlled" open={@show_popover} placement="bottom-end">
  <.button>Toggle</.button>

  <:content>
    <p>Content driven by the server.</p>
  </:content>
</.popover>

open is opt-in

Omit open (the default) for client-driven popovers; clicks, hover, focus, Escape, and outside-click all toggle local state without round-tripping the server. Set open only when the server actually needs to be authoritative. Rendering data-open on every re-render lets LiveView's DOM patcher overwrite client state any time an unrelated assign updates inside the panel.

Push events from a handler

Fluxon.open_popover/2 and Fluxon.close_popover/2 queue a push_event on the socket:

def handle_event("show_help", _, socket) do
  {:noreply, Fluxon.open_popover(socket, "help-popover")}
end

on_open and on_close callbacks

Attach Phoenix.LiveView.JS commands that run after the popover finishes its enter or leave animation. Use these to sync server state when the user dismisses a popover via Escape, an outside-click, or hover-out:

<.popover
  id="filter-popover"
  open={@filters_open}
  on_close={JS.push("filters_dismissed")}
>
  <.button>Filters</.button>

  <:content>
    <!-- ... -->
  </:content>
</.popover>

External Target Positioning

The target attribute points at any element on the page via CSS selector. The popover positions itself relative to that element instead of its inner block, hover and focus interactions are disabled, and the panel becomes programmatic-only.

Multi-step workflows

Anchor several popovers to the same trigger to build a step-by-step flow:

<.button id="workflow-trigger" phx-click={Fluxon.open_popover("step-1")}>
  Start workflow
</.button>

<!-- Step 1: anchored to the button above -->
<.popover id="step-1" target="#workflow-trigger" placement="bottom">
  <:content>
    <div class="space-y-3 p-2">
      <p class="text-sm font-medium">Step 1: choose option</p>
      <div class="space-y-2">
        <.button
          size="sm"
          class="w-full justify-start"
          phx-click={Fluxon.close_popover("step-1") |> Fluxon.open_popover("step-2")}
        >
          Option A
        </.button>
        <.button
          size="sm"
          class="w-full justify-start"
          phx-click={Fluxon.close_popover("step-1") |> Fluxon.open_popover("step-2")}
        >
          Option B
        </.button>
      </div>
    </div>
  </:content>
</.popover>

<!-- Step 2: also anchored to the same button -->
<.popover id="step-2" target="#workflow-trigger" placement="bottom">
  <:content>
    <div class="space-y-3 p-2">
      <p class="text-sm font-medium">Step 2: confirm</p>
      <p class="text-sm text-foreground-softer">Ready to proceed?</p>
      <div class="flex gap-2">
        <.button size="sm" phx-click={Fluxon.close_popover("step-2")}>
          Finish
        </.button>
        <.button
          size="sm"
          variant="ghost"
          phx-click={Fluxon.close_popover("step-2") |> Fluxon.open_popover("step-1")}
        >
          Back
        </.button>
      </div>
    </div>
  </:content>
</.popover>

Remote anchors

Position a popover relative to a layout element while keeping the trigger elsewhere on the page:

<.button phx-click={Fluxon.open_popover("remote-menu")}>
  Show menu
</.button>

<div id="menu-anchor" class="some-layout-element">
  Content here...
</div>

<.popover id="remote-menu" target="#menu-anchor" placement="right-start">
  <:content>
    <div class="p-2">Menu positioned relative to the anchor element.</div>
  </:content>
</.popover>

Target mode constraints

When target is set:

  • The popover positions relative to the target element, not its own children.
  • open_on_hover and open_on_focus are ignored.
  • The popover opens and closes only via Fluxon.open_popover/1 and Fluxon.close_popover/1 (or their socket and %JS{} variants).
  • The :inner_block slot becomes optional and can be omitted entirely.

Customization

Pass a class to the popover to override width, padding, or surface color on the panel:

<.popover class="w-80 p-6">
  <.button>Open</.button>
  <:content>...</:content>
</.popover>

The panel ships with fixed positioning, padding, rounded corners, a shadow, and a border drawn from Fluxon's surface tokens, so passing a single class is usually enough to fit a popover into a custom layout.

Summary

Components

Renders a floating panel anchored to a trigger element.

Components

popover(assigns)

Renders a floating panel anchored to a trigger element.

Use a popover when the panel needs to hold rich, focusable, or interactive content such as forms, menus, settings, or search suggestions. The panel positions itself relative to the trigger, flips and shifts to stay inside the viewport, and animates in and out. The active trigger mode (click, hover, focus, programmatic) is inferred from the attributes passed.

Attributes

  • id (:string) - Identifier for the popover wrapper. Auto-generated when omitted. Required when controlling the popover programmatically via Fluxon.open_popover/1 and Fluxon.close_popover/1, when binding to a server assign through open, or whenever LiveView re-renders the popover (for example its content depends on an assign that changes) while it can be open.

    Without an explicit id the auto-generated one is regenerated on every render, so a patch that re-renders the popover hands its hook a new id, LiveView remounts the hook, and an open popover closes. A stable id lets LiveView patch it in place and keep it open.

  • target (:string) - CSS selector pointing at an external element to use as the positioning anchor. When set, the popover positions itself relative to that element instead of its inner block, hover and focus interactions are disabled, and the panel becomes programmatic-only: opened and closed exclusively through Fluxon.open_popover/1 and Fluxon.close_popover/1. Use for multi-step flows that share a single trigger, or for popovers anchored to a layout element while the trigger lives elsewhere on the page. The selector is resolved on mount, so the target element must already be in the DOM at that point.

    Defaults to nil.

  • class (:any) - Additional CSS classes merged onto the floating panel. Stacked on top of the base padding, rounded corners, shadow, border, and surface background, so utilities passed here win. Use this to cap the panel width (w-64, max-w-xs), tighten padding (p-2), or swap the surface color for a contextual variant.

    Defaults to nil.

  • open_on_hover (:boolean) - Opens the popover on mouseenter of the trigger and closes it shortly after mouseleave. The panel stays open while the cursor is over it, so links and buttons inside remain reachable. Suppressed automatically on touch devices, where hover does not exist as a reliable signal; click mode takes over there. Best for information bubbles and link previews where the user does not need to commit a click to see the content.

    Defaults to false.

  • open_on_focus (:boolean) - Opens the popover when any element inside the wrapper receives focus and closes it when focus leaves. Best suited for inputs that surface suggestions or contextual help while the user is typing. Click toggling is disabled in this mode so re-focusing the input does not flip the panel closed; Escape is also suppressed so the user can dismiss the popover by tabbing or clicking away rather than losing the field.

    Defaults to false.

  • placement (:string) - Preferred side of the trigger to place the popover on. The panel flips to the opposite side when the preferred side does not fit and shifts along the cross axis to stay inside the viewport.

    • top, bottom, left, right: place the popover on the named side, centered along the cross axis. Default sides for most contexts.
    • *-start variants (top-start, bottom-start, left-start, right-start): align the popover's leading edge with the trigger's leading edge. Use when the panel is wider than the trigger and should extend toward the end of the row.
    • *-end variants (top-end, bottom-end, left-end, right-end): align the popover's trailing edge with the trigger's trailing edge. Common for buttons near the right edge of a toolbar so the panel does not overflow the viewport.

    Defaults to "top". Must be one of "top", "top-start", "top-end", "right", "right-start", "right-end", "left", "left-start", "left-end", "bottom", "bottom-start", or "bottom-end".

  • open (:boolean) - Server-controlled open state. When omitted (the default), the popover is purely client-driven: clicks, hover, focus, Escape, and outside-click all toggle local state with no server involvement.

    When set to true or false, the popover binds its open state to the assign and the server becomes authoritative; updating the assign opens or closes the popover with its enter/leave animation. Pair with on_close={JS.push(...)} so client-driven dismissals push back to the server and keep the assign in sync.

    Why opt-in?

    Rendering data-open on every re-render would let LiveView's DOM patcher overwrite client-set state any time an unrelated assign changes inside the popover content (for example, closing a popover the user just opened with a click). Omitting the attribute when the server has no opinion lets local state survive re-renders cleanly.

    Defaults to nil.

  • on_open (Phoenix.LiveView.JS) - Phoenix.LiveView.JS commands to run after the popover finishes opening (after the enter animation completes).

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

  • on_close (Phoenix.LiveView.JS) - Phoenix.LiveView.JS commands to run after the popover finishes closing (after the leave animation completes). Fires for every dismissal path: Escape, outside-click, hover-out, and server-pushed close.

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

Slots

  • inner_block - The trigger element. The first child of the slot is treated as the trigger and receives click, hover, or focus wiring depending on the active mode. Optional when target is set, since the external element becomes the effective trigger; in that case the inner block can be omitted entirely.

  • content (required) - The panel content. Accepts arbitrary HEEx, including forms, lists, switches, links, and nested components. Rendered inside a fixed-position panel with a surface background, padding, rounded corners, and shadow.