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.json2. Use it
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
/** @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
{% 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
{# 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
{{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
{{/* 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
<.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
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
<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
<!--
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 [&:not(:popover-open)]:hidden [&: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 [&_svg]:size-4 [&_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 [&_svg]:size-4 [&_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 [&_svg]:size-4 [&_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 [&_svg]:size-4 [&_svg]:shrink-0 text-destructive focus:bg-destructive/10 focus:text-destructive">Log out</button>
</div>
</div>Further reading
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 [&:not(:popover-open)]:hidden [&: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 [&_svg]:size-4 [&_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 [&_svg]:size-4 [&_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 [&_svg]:size-4 [&_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>
| Prop | Type | Default | Description |
|---|---|---|---|
ariaLabelledBy | string | — | 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 |
ariaLabel | string | — | 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. |
class | string | — | Extra Tailwind classes appended to the root element. |
* required