Files
Htmx/docs/Components/DropdownMenu.md
T
2026-05-05 23:55:26 +05:00

5.3 KiB

DropdownMenu

A button that, when clicked, opens a small floating menu of links. Like a folder label on a filing cabinet — pull it and a list of options drops down. Closes automatically when you click elsewhere or press Escape.


Quick example

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

All the options

public DropdownMenu(
    IHtmxComponent trigger,
    IEnumerable<(string Label, string Href, bool IsSeparator)> items,
    string position = "right")
Parameter What it does
trigger The visible button that opens the menu. Any IHtmxComponent — usually a Button.
items The list of menu items. Each is a (Label, Href, IsSeparator) tuple.
position "right" aligns the menu to the right edge of the trigger (default). "left" aligns it to the left.

Item tuple fields:

Field What it does
Label The text shown in the menu.
Href The URL to navigate to when clicked.
IsSeparator Set to true to render a divider line instead of a link. Label and Href are ignored.

Real-world examples

User account menu in the header

new DropdownMenu(
    trigger: new Button("My account", variant: "ghost"),
    items: new[]
    {
        ("Profile",   "/profile",   false),
        ("Billing",   "/billing",   false),
        ("",           "",           true),
        ("Sign out",   "/logout",    false),
    })

Row action menu in a table (three-dot icon button)

new DropdownMenu(
    trigger: new Button("⋯", size: "icon", variant: "ghost"),
    items: new[]
    {
        ("Edit",   $"/items/{item.Id}/edit",   false),
        ("Delete", $"/items/{item.Id}/delete", false),
    },
    position: "left")  // avoid overflow near the right edge of the screen

Left-aligned menu (near right side of viewport)

Use position: "left" when the trigger is close to the right edge of the screen to prevent the menu from clipping off-screen:

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

How it works

The menu panel is always present in the HTML but hidden with a hidden class. When the trigger is clicked, JavaScript toggles the hidden class to show it. Clicking anything outside — or pressing Escape — adds hidden back.

Because the click listener is attached to document, dropdown menus that are HTMX-swapped in work automatically.

All items are rendered as <a href="..."> links. If you need an action that POSTs data (like a delete), the cleanest approach is to route it through a confirmation Dialog.


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");
    }
}