Fluxon.Components.Dropdown (Fluxon v3.0.0)

A menu of actions and links anchored to a trigger button, with keyboard navigation, hover intent, and nested submenus.

Dropdown is a compound component family backed by a JavaScript controller. The server renders a toggle, a floating menu panel, and an optional set of sibling submenu panels; the controller positions panels with Floating UI, runs the open and close animations, and tracks a highlighted item per menu. The toggle keeps focus the entire time the menu is open: arrow keys move a visible highlight between items rather than moving DOM focus, so the user never loses their place on the trigger.

The system is composed of pieces designed to be used together:

  • dropdown/1: the container that mounts the JavaScript hook, renders the toggle, the root menu panel, and any submenu panels declared through the :submenu slot.
  • dropdown_link/1: a navigable menu item rendered as a Phoenix <.link>. Use for navigate, patch, or plain href items.
  • dropdown_button/1: an action menu item rendered as a <button>. Use for phx-click handlers, Phoenix.LiveView.JS commands, and other non-navigation actions.
  • dropdown_submenu_trigger/1: an item that opens a linked :submenu panel. References the submenu by id and shows a right-pointing chevron.
  • dropdown_header/1: a non-interactive label for a group of items.
  • dropdown_separator/1: a thin divider between groups.
  • dropdown_custom/1: an arbitrary content area for avatars, summaries, or any markup that is not a menu item.

A typical structure looks like this:

dropdown
 :toggle slot              (optional; falls back to a default <.button>)
 dropdown_custom           (optional, header card)
 dropdown_header           (optional, group label)
 dropdown_link / dropdown_button   (interactive menu items)
 dropdown_separator        (optional, visual divider)
 dropdown_submenu_trigger  (optional, opens a submenu)
 :submenu slot             (one panel per submenu, matched by `id`)
     dropdown_link / dropdown_button
     dropdown_submenu_trigger      (submenus can nest indefinitely)

Choosing between dropdown and popover

Reach for dropdown when the floating panel is a list of menu items: actions, navigation targets, or hierarchical groups that the user picks one of. Dropdown builds the menu pattern for you: a moving highlight, arrow-key navigation between items, and submenu open and close.

Reach for Fluxon.Components.Popover when the floating panel is arbitrary interactive content: a settings panel with switches, a filter form, a help blurb. Popover does not impose menu semantics or roving highlight.

Usage

A dropdown with a default toggle and a few navigation links:

<.dropdown>
  <.dropdown_link navigate={~p"/profile"}>Profile</.dropdown_link>
  <.dropdown_link navigate={~p"/settings"}>Settings</.dropdown_link>
  <.dropdown_separator />
  <.dropdown_link href={~p"/sign_out"} method="delete">Sign out</.dropdown_link>
</.dropdown>

The default toggle renders a Fluxon.Components.Button.button/1 labelled "Menu" with a chevron icon. Pass label to change the text, or render a fully custom toggle through the :toggle slot. The menu opens on click and stays anchored to the toggle while it is open.

Mix dropdown_link/1 (navigation) and dropdown_button/1 (actions) freely. Both take the highlight and participate in keyboard navigation. Activating either kind of item closes the menu and returns focus to the toggle button:

<.dropdown label="Actions" placement="bottom-end">
  <.dropdown_button phx-click="duplicate">Duplicate</.dropdown_button>
  <.dropdown_button phx-click="archive">Archive</.dropdown_button>
  <.dropdown_separator />
  <.dropdown_button phx-click="delete" phx-confirm="Delete this record?" class="text-danger-foreground">
    Delete
  </.dropdown_button>
</.dropdown>

Custom Toggle

When the default <.button> toggle is not the shape you want, use the :toggle slot. The dropdown renders the slot inside a single focusable element that carries the menu behavior and its disabled state, so the toggle can take any shape: an avatar pill, an icon button, a full-width account card. You bring the content and the styling; the dropdown makes it open, close, and respond to the keyboard like a menu button. Style it entirely through the slot's class:

<.dropdown>
  <:toggle class="flex items-center gap-x-2 rounded-lg p-2 hover:highlight">
    <img src={~p"/images/human-avatar-01.png"} alt="User" class="size-6 rounded-lg" />
    <span class="text-sm font-semibold">Emma Johnson</span>
    <.icon name="hero-chevron-down" class="size-4" />
  </:toggle>

  <.dropdown_link navigate={~p"/profile"}>Profile</.dropdown_link>
  <.dropdown_link navigate={~p"/billing"}>Billing</.dropdown_link>
  <.dropdown_separator />
  <.dropdown_link href={~p"/sign_out"} method="delete">Sign out</.dropdown_link>
</.dropdown>

The content must be non-interactive: provide text, icons, images, and layout markup, never a nested button or link, since that would create a second focusable control inside the toggle. The toggle stays a single focusable element with one tab stop. For an icon-only toggle, give it an accessible name with visually hidden text:

<:toggle class="rounded-md p-1.5 hover:highlight">
  <.icon name="hero-ellipsis-horizontal" class="size-4" />
  <span class="sr-only">Open menu</span>
</:toggle>

Use either label or :toggle, not both

When the :toggle slot is provided, label and toggle_class are ignored: the slot supplies the toggle's content and styling. Provide label only when relying on the default toggle.

Disabled Items

Disabled items are visually dimmed, skipped by ArrowUp and ArrowDown navigation, and cannot be activated by click, Enter, or Space. The two kinds of menu items use different attributes because anchor elements do not support the native disabled attribute:

<.dropdown>
  <.dropdown_button>Account</.dropdown_button>
  <.dropdown_button disabled>Upgrade plan</.dropdown_button>
  <.dropdown_separator />
  <.dropdown_link navigate={~p"/settings"}>Settings</.dropdown_link>
  <.dropdown_link navigate={~p"/admin"} data-disabled>Admin panel</.dropdown_link>
</.dropdown>
ItemDisable withWhy
dropdown_button/1disabledNative HTML attribute on <button>
dropdown_link/1data-disabled<a> has no native disabled
dropdown_submenu_trigger/1disabledRenders as <button>

Both produce the same visual and behavioural result: dimmed text, no pointer events, and the item is skipped during keyboard navigation.

Rich Content

Combine headers, separators, and custom areas to build menus that go beyond a flat list of links. Headers, separators, and custom content are non-interactive: they are skipped by ArrowUp and ArrowDown, and hovering them clears the current item highlight and closes any open child submenu at the same level.

<.dropdown class="w-64">
  <.dropdown_custom class="flex items-center p-2">
    <img src={~p"/images/avatar.png"} alt="" class="size-9 rounded-full" />
    <div class="flex flex-col ml-3">
      <span class="text-sm font-medium">Emma Johnson</span>
      <span class="text-xs text-foreground-softer">emma@acme.com</span>
    </div>
  </.dropdown_custom>

  <.dropdown_separator />

  <.dropdown_header>Account</.dropdown_header>
  <.dropdown_link navigate={~p"/profile"}>Profile</.dropdown_link>
  <.dropdown_link navigate={~p"/billing"}>Billing</.dropdown_link>

  <.dropdown_header>Support</.dropdown_header>
  <.dropdown_link navigate={~p"/help"}>Documentation</.dropdown_link>
  <.dropdown_link navigate={~p"/contact"}>Contact us</.dropdown_link>

  <.dropdown_separator />

  <.dropdown_link href={~p"/sign_out"} method="delete" class="text-danger-foreground">
    Sign out
  </.dropdown_link>
</.dropdown>

Custom content can hold focusable elements (buttons, inputs). They are reachable via Tab but are not part of the menu's roving highlight, so arrow-key navigation still flows through the surrounding menu items.

Hover Interaction

Set open_on_hover to open the dropdown when the cursor enters the toggle. Moving from the toggle to the menu panel keeps it open; moving away from both starts the close timer. hover_open_delay and hover_close_delay add intent buffers to prevent accidental activation:

<.dropdown open_on_hover hover_open_delay={150} hover_close_delay={250}>
  <.dropdown_link navigate={~p"/profile"}>Profile</.dropdown_link>
  <.dropdown_link navigate={~p"/settings"}>Settings</.dropdown_link>
</.dropdown>

Click still toggles the dropdown when open_on_hover is enabled, and keyboard activation through Enter, Space, ArrowDown, or ArrowUp still works the same way. The hover delays apply only to the cursor path; keyboard activation is immediate.

Positioning

The placement attribute controls where the menu renders relative to the toggle. The controller uses Floating UI's flip and shift middleware so the panel automatically reorients to stay inside the viewport, and size middleware caps the height and turns on vertical scrolling when content overflows.

<.dropdown placement="bottom-end">
  <.dropdown_link navigate={~p"/profile"}>Profile</.dropdown_link>
</.dropdown>

<.dropdown placement="right-start">
  <.dropdown_link navigate={~p"/settings"}>Settings</.dropdown_link>
</.dropdown>
Placement familyWhen to use
bottom-*Default for header bars, toolbars, and form-row menus.
top-*Triggers near the bottom of the viewport (footers, sticky bars).
right-*Sidebars or vertical menus where the panel should expand sideways.
left-*Right-aligned triggers where a right-anchored panel would clip.

The -start and -end suffixes align the panel to the start or end edge of the trigger along the cross axis, while plain top, bottom, left, and right center it.

Submenus

Build hierarchical menus with the :submenu slot and dropdown_submenu_trigger/1. The trigger lives inside the parent menu and references its submenu by id; the submenu panel is declared as a sibling slot, not nested inside the trigger. The controller renders submenu panels as DOM siblings of the root menu so they can extend past the parent's boundaries without being clipped by overflow: hidden.

<.dropdown>
  <.dropdown_link navigate={~p"/profile"}>Profile</.dropdown_link>
  <.dropdown_link navigate={~p"/settings"}>Settings</.dropdown_link>
  <.dropdown_separator />

  <.dropdown_submenu_trigger submenu="more">More options</.dropdown_submenu_trigger>

  <:submenu id="more">
    <.dropdown_link navigate={~p"/preferences"}>Preferences</.dropdown_link>
    <.dropdown_link navigate={~p"/integrations"}>Integrations</.dropdown_link>
    <.dropdown_link navigate={~p"/notifications"}>Notifications</.dropdown_link>
  </:submenu>
</.dropdown>

Each :submenu slot needs an id that matches the submenu attribute of its trigger. A trigger with no matching :submenu becomes a regular menu item that does nothing on ArrowRight, and a :submenu with no trigger is unreachable.

Match submenu and id exactly

The link between trigger and panel is a string equality check on submenu and id. Typos silently break the relationship: the trigger renders, but pressing ArrowRight or hovering it does nothing because the controller cannot find the panel.

Submenus default to right-start placement. Override per submenu through the slot's placement attribute:

<:submenu id="more" placement="left-start">
  <!-- Opens to the left of the trigger instead of the right -->
</:submenu>

Nested Submenus

Submenus can hold their own dropdown_submenu_trigger/1 items, which reference yet another :submenu slot. There is no enforced nesting limit; each level opens and closes independently:

<.dropdown>
  <.dropdown_submenu_trigger submenu="file">File</.dropdown_submenu_trigger>

  <:submenu id="file">
    <.dropdown_button phx-click="new_file">New</.dropdown_button>
    <.dropdown_submenu_trigger submenu="export">Export</.dropdown_submenu_trigger>
  </:submenu>

  <:submenu id="export">
    <.dropdown_button phx-click="export" phx-value-format="pdf">PDF</.dropdown_button>
    <.dropdown_button phx-click="export" phx-value-format="csv">CSV</.dropdown_button>
  </:submenu>
</.dropdown>

When a submenu opens, its parent trigger stays highlighted so the user can see where they came from. Hovering a sibling menu item, a header, a separator, or a custom content area closes any open submenu at the same level. Mouse hover on a submenu trigger uses an 80ms intent delay before opening, which prevents accidental activation when the cursor passes diagonally over the trigger on its way to a sibling.

Opening and Closing

The toggle keeps focus the whole time the menu is open, so opening and moving through the menu never pulls focus away from the trigger. The menu opens when the user clicks the toggle, presses Enter or Space on it, or presses an arrow key: ArrowDown opens with the first enabled item highlighted, and ArrowUp opens with the last enabled item highlighted, which reaches the bottom of a long menu in one keystroke. With open_on_hover set, moving the cursor onto the toggle opens it too.

While the menu is open, ArrowDown and ArrowUp move the highlight between enabled items. The highlight does not wrap at the top or bottom, so a held arrow key settles on the first or last item instead of cycling. Disabled items, headers, separators, and custom content are skipped.

The menu closes when the user activates an item, clicks the toggle again, clicks outside the panel, or presses Tab (which lets focus move on naturally). Pressing Escape closes every open menu and submenu at once. Both Escape and activating an item return focus to the toggle, so the user lands back where they started.

Examples

Row-action menu in a table, opening above the row on the last few rows so the panel does not get clipped by the page footer:

<.table id="invoices" rows={@invoices}>
  <:col :let={invoice} label="Number">{invoice.number}</:col>
  <:col :let={invoice} label="Status">{invoice.status}</:col>
  <:col :let={invoice} label="">
    <.dropdown placement="bottom-end">
      <:toggle class="rounded-md p-1.5 hover:highlight">
        <.icon name="hero-ellipsis-horizontal" class="size-4" />
        <span class="sr-only">Actions for invoice {invoice.number}</span>
      </:toggle>

      <.dropdown_link navigate={~p"/invoices/#{invoice.id}"}>View</.dropdown_link>
      <.dropdown_button phx-click="resend" phx-value-id={invoice.id}>Resend email</.dropdown_button>
      <.dropdown_separator />
      <.dropdown_button
        phx-click="void"
        phx-value-id={invoice.id}
        phx-confirm="Void this invoice?"
        class="text-danger-foreground"
      >
        Void
      </.dropdown_button>
    </.dropdown>
  </:col>
</.table>

User account menu with an avatar header, grouped sections, and a destructive sign-out action separated from the rest:

<.dropdown class="w-64" placement="bottom-end">
  <:toggle class="flex items-center gap-2 rounded-full p-1 hover:highlight">
    <img src={@current_user.avatar_url} alt="" class="size-8 rounded-full" />
    <.icon name="hero-chevron-down" class="size-4 text-foreground-softer" />
  </:toggle>

  <.dropdown_custom class="flex flex-col gap-0.5 px-2 py-2">
    <span class="text-sm font-medium">{@current_user.name}</span>
    <span class="text-xs text-foreground-softer">{@current_user.email}</span>
  </.dropdown_custom>

  <.dropdown_separator />

  <.dropdown_header>Account</.dropdown_header>
  <.dropdown_link navigate={~p"/profile"}>Profile</.dropdown_link>
  <.dropdown_link navigate={~p"/billing"}>Billing</.dropdown_link>

  <.dropdown_header>Workspace</.dropdown_header>
  <.dropdown_link navigate={~p"/team"}>Team</.dropdown_link>
  <.dropdown_link navigate={~p"/integrations"}>Integrations</.dropdown_link>

  <.dropdown_separator />

  <.dropdown_link
    href={~p"/sign_out"}
    method="delete"
    class="text-danger-foreground"
  >
    Sign out
  </.dropdown_link>
</.dropdown>

Hover-driven nav menu with submenus for a marketing header, where keyboard users can still click or press Enter on the toggle:

<.dropdown open_on_hover hover_open_delay={120} hover_close_delay={200} label="Products">
  <.dropdown_submenu_trigger submenu="cloud">Cloud</.dropdown_submenu_trigger>
  <.dropdown_submenu_trigger submenu="self-hosted">Self-hosted</.dropdown_submenu_trigger>
  <.dropdown_separator />
  <.dropdown_link navigate={~p"/pricing"}>Pricing</.dropdown_link>

  <:submenu id="cloud">
    <.dropdown_link navigate={~p"/cloud/starter"}>Starter</.dropdown_link>
    <.dropdown_link navigate={~p"/cloud/team"}>Team</.dropdown_link>
    <.dropdown_link navigate={~p"/cloud/enterprise"}>Enterprise</.dropdown_link>
  </:submenu>

  <:submenu id="self-hosted">
    <.dropdown_link navigate={~p"/self-hosted/community"}>Community</.dropdown_link>
    <.dropdown_link navigate={~p"/self-hosted/business"}>Business</.dropdown_link>
  </:submenu>
</.dropdown>

File menu with Phoenix.LiveView.JS commands wired to actions in the surrounding view:

<.dropdown label="File">
  <.dropdown_button phx-click={JS.push("file:new")}>New</.dropdown_button>
  <.dropdown_button phx-click={JS.dispatch("editor:save")}>Save</.dropdown_button>
  <.dropdown_separator />

  <.dropdown_submenu_trigger submenu="export-as">Export as</.dropdown_submenu_trigger>

  <:submenu id="export-as">
    <.dropdown_button phx-click="export" phx-value-format="pdf">PDF</.dropdown_button>
    <.dropdown_button phx-click="export" phx-value-format="csv">CSV</.dropdown_button>
    <.dropdown_button phx-click="export" phx-value-format="json">JSON</.dropdown_button>
  </:submenu>

  <.dropdown_separator />
  <.dropdown_button phx-click={JS.dispatch("editor:close")}>Close window</.dropdown_button>
</.dropdown>

Server-Side Control

Open, close, or toggle a dropdown from anywhere without a reference to its toggle by giving it an id and targeting it by command. From a template or another component, dispatch a command to the dropdown's id with Phoenix.LiveView.JS.dispatch/3:

<.button phx-click={JS.dispatch("fluxon:dropdown:open", to: "#account-menu")}>
  Open account menu
</.button>

<.dropdown id="account-menu">
  <.dropdown_link navigate={~p"/profile"}>Profile</.dropdown_link>
</.dropdown>

The commands are fluxon:dropdown:open, fluxon:dropdown:close, and fluxon:dropdown:toggle. To drive the same commands from the server, push them prefixed with phx: and pass the target id in the payload:

def handle_event("sign_out", _params, socket) do
  {:noreply, push_event(socket, "fluxon:dropdown:close", %{id: "account-menu"})}
end

To run Phoenix.LiveView.JS commands each time the menu finishes opening or closing, pass on_open and on_close. They fire after the enter and leave animations complete, which is useful for syncing external state, toggling a class on the trigger, or notifying the server:

<.dropdown
  id="filters"
  on_open={JS.push("menu_opened")}
  on_close={JS.push("menu_closed")}
>
  <.dropdown_button phx-click="reset_filters">Reset filters</.dropdown_button>
</.dropdown>

Summary

Components

Renders the dropdown container, toggle, root menu panel, and any declared submenu panels.

Renders an action menu item as a <button type="button">.

Renders an arbitrary content area inside a dropdown menu.

Renders a non-interactive section label inside a dropdown menu.

Renders a navigable menu item as a Phoenix <.link>.

Renders a thin horizontal divider between groups of dropdown items.

Renders a menu item that opens a linked submenu panel.

Components