shshadcn-htmx

Components

Dropdown Menu

A menu of actions opened from a button. Built on the native Popover API + APG menu keyboard contract — arrows, Home/End, type-to-find, Enter/Space activate.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/dropdown-menu.json

2. Use it

components/ui/dropdown-menu.tsx
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuItem,
  DropdownMenuSeparator, DropdownMenuLabel } from "@/components/ui/dropdown-menu"

<DropdownMenuTrigger menuFor="user-menu" class="…btn classes…">Account</DropdownMenuTrigger>

<DropdownMenu id="user-menu">
  <DropdownMenuLabel>My account</DropdownMenuLabel>
  <DropdownMenuItem>Profile</DropdownMenuItem>
  <DropdownMenuItem>Settings</DropdownMenuItem>
  <DropdownMenuSeparator />
  <DropdownMenuItem variant="destructive">Log out</DropdownMenuItem>
</DropdownMenu>
Or copy the source manually
components/ui/dropdown-menu.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Dropdown Menu — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Built on top of the native HTML Popover API (popover + popovertarget),
// so the platform gives us:
//   - Light dismiss when the user clicks outside.
//   - ESC closes the menu.
//   - Top-layer rendering.
//   - Focus restoration to the trigger after close.
//
// On top, public/site.js implements the APG menu keyboard contract:
//   - ArrowDown / ArrowUp move focus between menuitems.
//   - Home / End jump to first / last.
//   - Type-to-find: pressing a letter focuses the next menuitem whose
//     label starts with that letter.
//   - Enter / Space activate the focused item.
//
// Refs:
//   repos/aria-practices/content/patterns/menu-button/
//   repos/mdn/files/en-us/web/accessibility/aria/reference/roles/menu_role/

export type DropdownMenuSide = "top" | "right" | "bottom" | "left"

type DropdownMenuProps = PropsWithChildren<{
  // Required — matches popovertarget on the trigger.
  id: string
  // Which side of the trigger the menu opens on. site.js reads data-side
  // and positions the popover via JS (CSS Anchor Positioning isn't yet
  // shipped in every browser).
  side?: DropdownMenuSide
  // role="menu" REQUIRES an accessible name. Point aria-labelledby at the
  // trigger's id (canonical menu-button wiring) or pass aria-label for
  // icon-only triggers.
  // menu_role: aria-labelledby="menubutton".
  ariaLabelledBy?: string
  ariaLabel?: string
  class?: ClassValue
}>

export function DropdownMenu(props: DropdownMenuProps) {
  const { id, side = "bottom", ariaLabelledBy, ariaLabel, class: className, children } = props
  return (
    <div
      id={id}
      popover="auto"
      role="menu"
      aria-labelledby={ariaLabelledBy}
      aria-label={ariaLabel}
      data-slot="dropdown-menu"
      data-side={side}
      class={cn(
        "z-50 m-0 min-w-[12rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
        "[&:not(:popover-open)]:hidden",
        "[&:popover-open]:animate-[scn-popover-in_120ms_ease-out]",
        className,
      )}
    >
      {children}
    </div>
  )
}

type DropdownMenuTriggerProps = PropsWithChildren<{
  menuFor: string
  class?: ClassValue
  id?: string
}>

export function DropdownMenuTrigger(props: DropdownMenuTriggerProps) {
  return (
    <button
      id={props.id}
      type="button"
      popovertarget={props.menuFor}
      popovertargetaction="toggle"
      data-slot="dropdown-menu-trigger"
      aria-haspopup="menu"
      // menu-button pattern: advertise the controlled menu and its state.
      // Initial state is collapsed; site.js flips aria-expanded on toggle.
      aria-controls={props.menuFor}
      aria-expanded="false"
      class={cn(props.class)}
    >
      {props.children}
    </button>
  )
}

type DropdownMenuItemProps = PropsWithChildren<{
  // Action when the item is activated. Most calls just need `onClickHref`
  // or pass htmx attrs; we keep the API thin.
  onclick?: string
  href?: string
  disabled?: boolean
  // Show as a destructive/danger menu item (red).
  variant?: "default" | "destructive"
  class?: ClassValue
  // htmx attributes ride along onto the button/anchor.
  "hx-get"?: string
  "hx-post"?: string
  "hx-target"?: string
  "hx-swap"?: string
  "hx-trigger"?: string
}>

export function DropdownMenuItem(props: DropdownMenuItemProps) {
  const { children, onclick, href, disabled, variant = "default", class: className, ...rest } = props
  const itemBase =
    "relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none " +
    "focus:bg-accent focus:text-accent-foreground " +
    "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 " +
    "[&_svg]:size-4 [&_svg]:shrink-0"
  const variantCls =
    variant === "destructive"
      ? "text-destructive focus:bg-destructive/10 focus:text-destructive"
      : ""
  const Tag: any = href ? "a" : "button"
  return (
    <Tag
      role="menuitem"
      type={href ? undefined : "button"}
      tabindex={-1}
      href={href}
      onclick={onclick}
      data-slot="dropdown-menu-item"
      data-disabled={disabled ? "true" : undefined}
      // menuitem_role: a disabled menuitem MUST set aria-disabled="true"
      // (data-disabled stays for the CSS/keyboard-skip selectors).
      aria-disabled={disabled ? "true" : undefined}
      class={cn(itemBase, variantCls, className)}
      {...rest}
    >
      {children}
    </Tag>
  )
}

export function DropdownMenuSeparator(props: { class?: ClassValue }) {
  return (
    <div
      role="separator"
      data-slot="dropdown-menu-separator"
      class={cn("-mx-1 my-1 h-px bg-border", props.class)}
    />
  )
}

export function DropdownMenuLabel(
  props: PropsWithChildren<{ class?: ClassValue }>,
) {
  return (
    <div
      data-slot="dropdown-menu-label"
      class={cn("px-2 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase", props.class)}
    >
      {props.children}
    </div>
  )
}

1. Save the file

Copy dropdown-menu.html into templates/components/.

2. Use it

templates/components/dropdown-menu.html
{% from "components/dropdown-menu.html" import
   dropdown_trigger, dropdown_open, dropdown_close, dropdown_item,
   dropdown_separator, dropdown_label %}

{{ dropdown_trigger("Account", menu_for="user-menu", class_="…btn…") }}

{% call dropdown_open(id="user-menu") %}
  {{ dropdown_label("My account") }}
  {{ dropdown_item("Profile") }}
  {{ dropdown_item("Settings") }}
  {{ dropdown_separator() }}
  {{ dropdown_item("Log out", variant="destructive") }}
{% endcall %}
View source
templates/components/dropdown-menu.html
{# DropdownMenu macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Native [popover] + APG menu keyboard contract via public/site.js. #}

{% macro dropdown_trigger(label, menu_for, class_="", id=none) %}
{# menu-button pattern: aria-controls + aria-expanded advertise the menu and
   its state. Initial state collapsed; site.js flips aria-expanded on toggle. #}
<button {% if id %}id="{{ id }}"{% endif %} type="button"
        popovertarget="{{ menu_for }}" popovertargetaction="toggle"
        data-slot="dropdown-menu-trigger" aria-haspopup="menu"
        aria-controls="{{ menu_for }}" aria-expanded="false"
        class="{{ class_ }}">{{ label }}</button>
{% endmacro %}

{% macro dropdown_open(id, side="bottom", extra_class="", aria_labelledby=none, aria_label=none) %}
{%- set sides = {
    "top":    "anchor-popover-top",
    "bottom": "anchor-popover-bottom",
    "left":   "anchor-popover-left",
    "right":  "anchor-popover-right"
} -%}
{# role="menu" REQUIRES an accessible name: aria-labelledby (canonical, points
   at the trigger id) or aria-label for icon-only triggers. menu_role. #}
<div id="{{ id }}" popover="auto" role="menu"
     {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
     {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
     data-slot="dropdown-menu" data-side="{{ side }}"
     class="z-50 m-0 min-w-[12rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out] {{ sides[side] }} {{ extra_class }}">
{% endmacro %}

{% macro dropdown_close() %}</div>{% endmacro %}

{% macro dropdown_item(label, href=none, onclick=none, disabled=false, variant="default", extra_class="", **attrs) %}
{%- set base -%}
relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0
{%- endset -%}
{%- set destructive -%}
{% if variant == "destructive" %}text-destructive focus:bg-destructive/10 focus:text-destructive{% endif %}
{%- endset -%}
{% if href %}
<a role="menuitem" tabindex="-1" href="{{ href }}"
   data-slot="dropdown-menu-item"
   {#- menuitem_role: a disabled menuitem MUST set aria-disabled="true". #}
   {%- if disabled %} data-disabled="true" aria-disabled="true"{% endif %}
   class="{{ base }} {{ destructive }} {{ extra_class }}"
   {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ label }}</a>
{% else %}
<button type="button" role="menuitem" tabindex="-1"
        {% if onclick %}onclick="{{ onclick }}"{% endif %}
        data-slot="dropdown-menu-item"
        {#- menuitem_role: a disabled menuitem MUST set aria-disabled="true". #}
        {%- if disabled %} data-disabled="true" aria-disabled="true"{% endif %}
        class="{{ base }} {{ destructive }} {{ extra_class }}"
        {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ label }}</button>
{% endif %}
{% endmacro %}

{% macro dropdown_separator() %}
<div role="separator" data-slot="dropdown-menu-separator" class="-mx-1 my-1 h-px bg-border"></div>
{% endmacro %}

{% macro dropdown_label(text) %}
<div data-slot="dropdown-menu-label" class="px-2 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">{{ text }}</div>
{% endmacro %}

1. Save the file

Add dropdown-menu.tmpl alongside button.tmpl.

2. Use it

templates/components/dropdown-menu.tmpl
{{template "dropdown_trigger" (dict "Label" "Account" "MenuFor" "user-menu" "Class" "…btn…")}}

{{template "dropdown_menu" (dict "ID" "user-menu" "Body" (htmlSafe `
  …label, items, separator…`))}}
View source
templates/components/dropdown-menu.tmpl
{{/* DropdownMenu templates — shadcn-htmx, htmx v4 + Tailwind v4.
     Native [popover] + APG menu keyboard contract via public/site.js. */}}

{{define "dropdown_trigger"}}
{{/* menu-button pattern: aria-controls + aria-expanded advertise the menu and
     its state. Initial state collapsed; site.js flips aria-expanded on toggle. */}}
<button {{if .ID}}id="{{.ID}}"{{end}} type="button"
        popovertarget="{{.MenuFor}}" popovertargetaction="toggle"
        data-slot="dropdown-menu-trigger" aria-haspopup="menu"
        aria-controls="{{.MenuFor}}" aria-expanded="false"
        class="{{.Class}}">{{.Label}}</button>
{{end}}

{{define "dropdown_menu"}}
{{/* role="menu" REQUIRES an accessible name: AriaLabelledBy (canonical, points
     at the trigger id) or AriaLabel for icon-only triggers. menu_role. */}}
<div id="{{.ID}}" popover="auto" role="menu"
     {{- if .AriaLabelledBy}} aria-labelledby="{{.AriaLabelledBy}}"{{end}}
     {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
     data-slot="dropdown-menu"
     class="z-50 m-0 min-w-[12rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out] anchor-popover-bottom">
  {{.Body}}
</div>
{{end}}

{{define "dropdown_item"}}
{{- $variant := or .Variant "default" -}}
{{- $base := "relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0" -}}
{{- $destr := "" -}}{{- if eq $variant "destructive" -}}{{- $destr = "text-destructive focus:bg-destructive/10 focus:text-destructive" -}}{{- end -}}
{{if .Href}}
<a role="menuitem" tabindex="-1" href="{{.Href}}" data-slot="dropdown-menu-item"
   {{/* menuitem_role: a disabled menuitem MUST set aria-disabled="true". */}}
   {{if .Disabled}}data-disabled="true" aria-disabled="true"{{end}}
   class="{{$base}} {{$destr}}"
   {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Label}}</a>
{{else}}
<button type="button" role="menuitem" tabindex="-1" data-slot="dropdown-menu-item"
        {{/* menuitem_role: a disabled menuitem MUST set aria-disabled="true". */}}
        {{if .Disabled}}data-disabled="true" aria-disabled="true"{{end}}
        {{- if .Onclick}} onclick="{{.Onclick}}"{{end}}
        class="{{$base}} {{$destr}}"
        {{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Label}}</button>
{{end}}
{{end}}

{{define "dropdown_separator"}}
<div role="separator" data-slot="dropdown-menu-separator" class="-mx-1 my-1 h-px bg-border"></div>
{{end}}

{{define "dropdown_label"}}
<div data-slot="dropdown-menu-label" class="px-2 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">{{.Text}}</div>
{{end}}

1. Save the file

Drop dropdown_menu.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/dropdown_menu.ex
<.dropdown_trigger menu_for="user-menu" class="…btn…">Account</.dropdown_trigger>

<.dropdown_menu id="user-menu">
  <.dropdown_label>My account</.dropdown_label>
  <.dropdown_item>Profile</.dropdown_item>
  <.dropdown_item>Settings</.dropdown_item>
  <.dropdown_separator />
  <.dropdown_item variant="destructive">Log out</.dropdown_item>
</.dropdown_menu>
View source
lib/my_app_web/components/dropdown_menu.ex
defmodule ShadcnHtmx.Components.DropdownMenu do
  @moduledoc """
  DropdownMenu — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Built on the native Popover API (popover + popovertarget). The APG menu
  keyboard contract (arrows, Home/End, type-to-find, Enter/Space activate)
  is wired up in public/site.js.

  ## Examples

      <.dropdown_trigger menu_for="user-menu" class="…btn…">Account</.dropdown_trigger>

      <.dropdown_menu id="user-menu">
        <.dropdown_label>My account</.dropdown_label>
        <.dropdown_item>Profile</.dropdown_item>
        <.dropdown_item>Settings</.dropdown_item>
        <.dropdown_separator />
        <.dropdown_item variant="destructive">Log out</.dropdown_item>
      </.dropdown_menu>
  """

  use Phoenix.Component

  attr :menu_for, :string, required: true
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def dropdown_trigger(assigns) do
    ~H"""
    <%!-- menu-button pattern: aria-controls + aria-expanded advertise the menu
          and its state. Initial state collapsed; site.js flips aria-expanded. --%>
    <button
      type="button"
      popovertarget={@menu_for}
      popovertargetaction="toggle"
      data-slot="dropdown-menu-trigger"
      aria-haspopup="menu"
      aria-controls={@menu_for}
      aria-expanded="false"
      class={@class}
      {@rest}
    >
      {render_slot(@inner_block)}
    </button>
    """
  end

  attr :id, :string, required: true
  attr :class, :string, default: nil
  # role="menu" REQUIRES an accessible name: aria_labelledby (canonical, points
  # at the trigger id) or aria_label for icon-only triggers. menu_role.
  attr :aria_labelledby, :string, default: nil
  attr :aria_label, :string, default: nil
  slot :inner_block, required: true

  def dropdown_menu(assigns) do
    ~H"""
    <div
      id={@id}
      popover="auto"
      role="menu"
      aria-labelledby={@aria_labelledby}
      aria-label={@aria_label}
      data-slot="dropdown-menu"
      class={[
        "z-50 m-0 min-w-[12rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
        "[&:not(:popover-open)]:hidden",
        "[&:popover-open]:animate-[scn-popover-in_120ms_ease-out]",
        "anchor-popover-bottom",
        @class
      ]}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end

  attr :href, :string, default: nil
  attr :disabled, :boolean, default: false
  attr :variant, :string, default: "default", values: ~w(default destructive)
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def dropdown_item(assigns) do
    base =
      "relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none " <>
        "focus:bg-accent focus:text-accent-foreground " <>
        "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 " <>
        "[&_svg]:size-4 [&_svg]:shrink-0"

    destr =
      if assigns.variant == "destructive",
        do: "text-destructive focus:bg-destructive/10 focus:text-destructive",
        else: ""

    assigns = assign(assigns, base: base, destr: destr)

    cond do
      assigns.href ->
        ~H"""
        <%!-- menuitem_role: a disabled menuitem MUST set aria-disabled="true". --%>
        <a
          role="menuitem"
          tabindex="-1"
          href={@href}
          data-slot="dropdown-menu-item"
          data-disabled={@disabled && "true"}
          aria-disabled={@disabled && "true"}
          class={[@base, @destr, @class]}
          {@rest}
        >
          {render_slot(@inner_block)}
        </a>
        """

      true ->
        ~H"""
        <%!-- menuitem_role: a disabled menuitem MUST set aria-disabled="true". --%>
        <button
          type="button"
          role="menuitem"
          tabindex="-1"
          data-slot="dropdown-menu-item"
          data-disabled={@disabled && "true"}
          aria-disabled={@disabled && "true"}
          class={[@base, @destr, @class]}
          {@rest}
        >
          {render_slot(@inner_block)}
        </button>
        """
    end
  end

  def dropdown_separator(assigns) do
    ~H"""
    <div role="separator" data-slot="dropdown-menu-separator" class="-mx-1 my-1 h-px bg-border" />
    """
  end

  attr :class, :string, default: nil
  slot :inner_block, required: true

  def dropdown_label(assigns) do
    ~H"""
    <div
      data-slot="dropdown-menu-label"
      class={[
        "px-2 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase",
        @class
      ]}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end
end

1. Save the file

Includes the keyboard contract script. Copy once per page.

2. Use it

index.html
<button popovertarget="user-menu" popovertargetaction="toggle"
        aria-haspopup="menu" class="…">Account</button>
<div id="user-menu" popover="auto" role="menu" data-slot="dropdown-menu" class="…">
  <button role="menuitem" tabindex="-1">Profile</button>
  <button role="menuitem" tabindex="-1">Settings</button>
</div>
View source
index.html
<!--
  shadcn-htmx — raw HTML dropdown menu snippet.

  Native [popover] for open/close + APG menu keyboard nav via the inline JS.
-->

<!-- menu-button pattern: aria-controls + aria-expanded advertise the menu and
     its state. Initial state collapsed; the inline JS flips aria-expanded. -->
<button id="user-menu-trigger" type="button" popovertarget="user-menu" popovertargetaction="toggle"
        data-slot="dropdown-menu-trigger" aria-haspopup="menu"
        aria-controls="user-menu" aria-expanded="false"
        class="inline-flex h-9 items-center rounded-md border bg-background px-4 text-sm font-medium shadow-xs hover:bg-accent">
  Account
</button>

<!-- role="menu" REQUIRES an accessible name: aria-labelledby points at the
     trigger id (canonical menu-button wiring), or use aria-label. menu_role. -->
<div id="user-menu" popover="auto" role="menu" aria-labelledby="user-menu-trigger" data-slot="dropdown-menu"
     class="z-50 m-0 min-w-[12rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out] anchor-popover-bottom">
  <div data-slot="dropdown-menu-label" class="px-2 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">My account</div>
  <button type="button" role="menuitem" tabindex="-1" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0">Profile</button>
  <button type="button" role="menuitem" tabindex="-1" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0">Settings</button>
  <div role="separator" class="-mx-1 my-1 h-px bg-border"></div>
  <button type="button" role="menuitem" tabindex="-1" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 text-destructive focus:bg-destructive/10 focus:text-destructive">Log out</button>
</div>

<!-- Keyboard contract — copy once per page -->
<script>
  document.querySelectorAll('[data-slot="dropdown-menu"]').forEach(function (menu) {
    menu.addEventListener('toggle', function (e) {
      if (e.newState === 'open') {
        var first = menu.querySelector('[role="menuitem"]:not([data-disabled="true"])')
        if (first) setTimeout(function () { first.focus() }, 0)
      }
    })
  })
  document.addEventListener('keydown', function (e) {
    var item = e.target.closest && e.target.closest('[role="menuitem"]'); if (!item) return
    var menu = item.closest('[data-slot="dropdown-menu"]'); if (!menu) return
    var items = [].slice.call(menu.querySelectorAll('[role="menuitem"]:not([data-disabled="true"])'))
    var i = items.indexOf(item)
    if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length].focus() }
    else if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus() }
    else if (e.key === 'Home') { e.preventDefault(); items[0].focus() }
    else if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus() }
    else if (e.key.length === 1 && /\S/.test(e.key)) {
      var ch = e.key.toLowerCase()
      for (var k = 1; k <= items.length; k++) {
        var c = items[(i + k) % items.length]
        if ((c.textContent || '').trim().toLowerCase().startsWith(ch)) { c.focus(); break }
      }
    }
  })
</script>

Examples

Basic — click trigger, then keyboard takes over

Open the menu. ↑/↓ cycles, Home/End jump, ESC closes, Enter activates. Type a letter to jump to the next matching item.

APG's menu-button pattern is dense — but most of it falls out for free when you start from popovertarget + role="menu" / role="menuitem". The site.js handler adds arrow keys, Home/End, and type-to-find. The browser's Popover API gives us light-dismiss + ESC + focus restoration.

<DropdownMenuTrigger menuFor="user-menu">Account ▾</DropdownMenuTrigger>
<DropdownMenu id="user-menu">
  <DropdownMenuLabel>My account</DropdownMenuLabel>
  <DropdownMenuItem>Profile</DropdownMenuItem>
  <DropdownMenuItem>Settings</DropdownMenuItem>
  <DropdownMenuSeparator />
  <DropdownMenuItem variant="destructive">Log out</DropdownMenuItem>
</DropdownMenu>
{{ dropdown_trigger("Account ▾", menu_for="user-menu", class_="…") }}
{% call dropdown_open(id="user-menu") %}
  {{ dropdown_label("My account") }}
  {{ dropdown_item("Profile") }}
  {{ dropdown_item("Settings") }}
  {{ dropdown_separator() }}
  {{ dropdown_item("Log out", variant="destructive") }}
{% endcall %}
{{template "dropdown_trigger" (dict "Label" "Account ▾" "MenuFor" "user-menu" "Class" "…")}}
{{template "dropdown_menu" (dict "ID" "user-menu" "Body" (htmlSafe `…`))}}
<.dropdown_trigger menu_for="user-menu" class="…">Account</.dropdown_trigger>
<.dropdown_menu id="user-menu">
  <.dropdown_label>My account</.dropdown_label>
  <.dropdown_item>Profile</.dropdown_item>
  <.dropdown_item variant="destructive">Log out</.dropdown_item>
</.dropdown_menu>
<div class="flex items-center justify-center">
  <button type="button" popovertarget="ex-ddm-1" popovertargetaction="toggle" data-slot="dropdown-menu-trigger" aria-haspopup="menu" aria-controls="ex-ddm-1" aria-expanded="false" class="inline-flex h-9 items-center rounded-md border bg-background px-4 text-sm font-medium shadow-xs hover:bg-accent">Account ▾</button>
  <div id="ex-ddm-1" popover="auto" role="menu" data-slot="dropdown-menu" data-side="bottom" class="z-50 m-0 min-w-[12rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none [&amp;:not(:popover-open)]:hidden [&amp;:popover-open]:animate-[scn-popover-in_120ms_ease-out]">
    <div data-slot="dropdown-menu-label" class="px-2 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">My account</div>
    <button role="menuitem" type="button" tabindex="-1" data-slot="dropdown-menu-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&amp;_svg]:size-4 [&amp;_svg]:shrink-0">Profile</button>
    <button role="menuitem" type="button" tabindex="-1" data-slot="dropdown-menu-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&amp;_svg]:size-4 [&amp;_svg]:shrink-0">Settings</button>
    <button role="menuitem" type="button" tabindex="-1" data-slot="dropdown-menu-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&amp;_svg]:size-4 [&amp;_svg]:shrink-0">Billing</button>
    <div role="separator" data-slot="dropdown-menu-separator" class="-mx-1 my-1 h-px bg-border">
    </div>
    <button role="menuitem" type="button" tabindex="-1" data-slot="dropdown-menu-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&amp;_svg]:size-4 [&amp;_svg]:shrink-0 text-destructive focus:bg-destructive/10 focus:text-destructive">Log out</button>
  </div>
</div>

Destructive item — red, last-position by convention

Destructive items get red colour and live below a separator at the bottom.

Convention places destructive actions at the bottom of a menu, separated by a divider, with a colour cue. The variant only changes the visual — the activation behaviour is identical, so confirm-before-destruction must happen separately (via a Dialog or htmx hx-confirm).

<DropdownMenuItem variant="destructive"
  hx-delete="/items/42" hx-confirm="Delete this item?">
  Delete…
</DropdownMenuItem>
{{ dropdown_item("Delete…", variant="destructive",
                  hx_delete="/items/42", hx_confirm="Delete this item?") }}
{{template "dropdown_item" (dict "Label" "Delete…" "Variant" "destructive")}}
<.dropdown_item variant="destructive"
  hx-delete={~p"/items/42"} hx-confirm="Delete this item?">
  Delete
</.dropdown_item>
<div class="flex items-center justify-center">
  <button type="button" popovertarget="ex-ddm-2" popovertargetaction="toggle" data-slot="dropdown-menu-trigger" aria-haspopup="menu" aria-controls="ex-ddm-2" aria-expanded="false" class="inline-flex h-9 items-center rounded-md border bg-background px-4 text-sm font-medium shadow-xs hover:bg-accent">More ▾</button>
  <div id="ex-ddm-2" popover="auto" role="menu" data-slot="dropdown-menu" data-side="bottom" class="z-50 m-0 min-w-[12rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none [&amp;:not(:popover-open)]:hidden [&amp;:popover-open]:animate-[scn-popover-in_120ms_ease-out]">
    <button role="menuitem" type="button" tabindex="-1" data-slot="dropdown-menu-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&amp;_svg]:size-4 [&amp;_svg]:shrink-0">Duplicate</button>
    <button role="menuitem" type="button" tabindex="-1" data-slot="dropdown-menu-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&amp;_svg]:size-4 [&amp;_svg]:shrink-0">Archive</button>
    <div role="separator" data-slot="dropdown-menu-separator" class="-mx-1 my-1 h-px bg-border">
    </div>
    <button role="menuitem" type="button" tabindex="-1" data-slot="dropdown-menu-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&amp;_svg]:size-4 [&amp;_svg]:shrink-0 text-destructive focus:bg-destructive/10 focus:text-destructive" hx-delete="/items/42" hx-confirm="Delete this item?">Delete…</button>
  </div>
</div>

Further reading

API Reference

<DropdownMenu>

PropTypeDefaultDescription
ariaLabelledBystring
ID of the trigger (or other element) whose text names the menu. role="menu" requires an accessible name; this is the canonical menu-button wiring. Emits aria-labelledby on the menu.MDNmenu role
ariaLabelstring
Accessible name for the menu when there is no visible label to reference (e.g. an icon-only trigger). Emits aria-label on the menu.MDNmenu role
id*string
Matches DropdownMenuTrigger's menuFor.
side"top"|"right"|"bottom"|"left""bottom"
Placement relative to trigger.
classstring
Extra Tailwind classes appended to the root element.

* required