Files
Htmx/docs/Components/DropdownMenu.md
T
2026-05-04 19:57:48 +05:00

6.2 KiB

DropdownMenu

A button that reveals a floating list of links or actions when clicked. Closes when the user clicks outside or presses Escape. Positioned below the trigger by default.


HTML structure

div.relative.inline-block         ← anchor for absolute positioning
  {trigger rendered inline}       ← any IHtmxComponent (usually a Button)
  div.dropdown-menu.absolute...   ← the floating panel; hidden by default
    div.w-48.rounded-md.border.bg-popover.shadow-md.p-1
      a.dropdown-item             ← link item
      hr.dropdown-separator       ← separator (when isSeparator=true)

CSS mechanics

Class Effect
hidden (initial) on panel Hides the dropdown until toggled by JS
absolute z-50 Floats above surrounding content
top-full mt-1 Placed below the trigger with a small gap
right-0 / left-0 Controlled by the position parameter
dropdown-item flex items-center px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer

JavaScript (delegated click in components.js)

Set up once on document — works for HTMX-swapped dropdowns.

Open / close toggle:

  1. Click on the trigger element ([data-dropdown-trigger]) → toggle .hidden on the sibling .dropdown-menu
  2. Click outside the dropdown root → close all open dropdowns
  3. Escape keydown → close all open dropdowns
  4. Click on a .dropdown-item link → close the parent dropdown and follow the link

Constructor signature

public DropdownMenu(
    IHtmxComponent trigger,
    IEnumerable<(string Label, string Href, bool IsSeparator)> items,
    string position = "right")
Parameter Description
trigger Any IHtmxComponent — shown as the visible toggle element
items Menu items; IsSeparator=true renders an <hr> (Label/Href ignored)
position "right" (default) aligns panel right edge; "left" aligns left edge

Usage examples

User menu

new DropdownMenu(
    trigger: new Button("Account", variant: "outline"),
    items: new[]
    {
        ("Profile",  "/profile",  false),
        ("Settings", "/settings", false),
        ("",          "",          true),   // separator
        ("Sign out",  "/logout",   false),
    })

Icon-button dropdown

new DropdownMenu(
    trigger: new Button("⋯", size: "icon", variant: "ghost"),
    items: new[]
    {
        ("Edit",   "/items/42/edit",   false),
        ("Delete", "/items/42/delete", false),
    })

Left-aligned dropdown (useful when near the right edge of the viewport)

new DropdownMenu(
    trigger: new Button("Actions"),
    items: actions,
    position: "left")

HTMX action items

Items use <a href> — if you need HTMX requests, override by building the HTML manually:

// Pass a synthetic IHtmxComponent for trigger and use a raw slot override
// for items that need hx-delete / hx-post, since items only support href links.
// Alternatively, use a Dialog for confirmation dialogs linked from the dropdown.

Tips and tricks

  • The trigger is any IHtmxComponent — pass a Button, an Avatar, or any custom component.
  • All items are rendered as <a href> links. For actions that should POST/DELETE, either route them through normal GET links to a form redirect, or pair them with a confirmation Dialog.
  • For a context menu that appears at a table row, pass new Button("⋯", size: "icon", variant: "ghost") as the trigger.
  • Setting position: "left" prevents the dropdown from overflowing the right side of the viewport when the trigger is near the right edge.
  • Multiple dropdowns on the same page are handled independently — clicking one will close others.
  • Multiple dropdowns on the same page are handled independently — clicking one will close others.

Complete page example

Templates/UserHeaderPage.htmx

<header class="border-b px-6 py-3 flex items-center justify-between">
  <a href="/" class="font-bold text-lg">MyApp</a>
  <div class="flex items-center gap-4">
    <a href="/dashboard" class="text-sm text-muted-foreground hover:text-foreground">Dashboard</a>
    $$UserMenu$$
  </div>
</header>
<main class="max-w-3xl mx-auto py-10">
  $$Body$$
</main>

Templates/UserHeaderPage.htmx.cs

namespace Htmx.ApiDemo.Templates;

public sealed class UserHeaderPage : UserHeaderPageBase
{
    private readonly IHtmxComponent _userMenu;
    private readonly IHtmxComponent _body;

    public UserHeaderPage(AppUser user, IHtmxComponent body)
    {
        _body = body;

        var trigger = new Components.Avatar(
            fallback: GetInitials(user.DisplayName),
            size: "sm");

        _userMenu = new Components.DropdownMenu(
            trigger: trigger,
            items: new[]
            {
                ("Profile",       "/profile",  false),
                ("Settings",      "/settings", false),
                ("",              "",           true),   // separator
                ("Sign out",      "/logout",   false),
            });
    }

    private static string GetInitials(string? name)
    {
        if (string.IsNullOrWhiteSpace(name)) return "?";
        var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        return parts.Length >= 2 ? $"{parts[0][0]}{parts[^1][0]}" : name[..1].ToUpperInvariant();
    }

    protected override void RenderUserMenu(HtmxRenderContext ctx) => _userMenu.Render(ctx.Next());
    protected override void RenderBody(HtmxRenderContext ctx)     => _body.Render(ctx.Next());
}

GET handler

[Handler]
[MapGet("/home")]
public static partial class GetHomeHandler
{
    public record Query();

    private static async Task<IResult> HandleAsync(
        Query _, HttpContext ctx, MongoDbService db, CancellationToken ct)
    {
        var email = ctx.User.FindFirst(ClaimTypes.Email)?.Value ?? "";
        var user  = await db.FindByNormalizedEmailAsync(email.ToUpperInvariant(), ct)
                    ?? new AppUser { Email = email };

        // The inner body can be any page component
        var innerBody = new WelcomePage(user);
        var page = new UserHeaderPage(user, innerBody);
        return await ctx.WriteHtmxPage(page, title: "Home");
    }
}