DropdownMenu
Floating menu with keyboard navigation, type-ahead, and click/Escape dismiss.
Installation
bin/rails g wabi:add dropdown_menu
bin/importmap pin @zag-js/menu
bin/importmap pin @zag-js/vanillaPin @zag-js/menu and @zag-js/vanilla at version 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only downloads the main entry and leaves submodule imports unresolved.
Composition with submenu
Edit
Share
Email
Slack
render Components::UI::DropdownMenu.new do
render Components::UI::DropdownMenuTrigger.new(
class: "inline-flex items-center justify-center rounded-md text-sm font-medium " "border border-input bg-background hover:bg-accent hover:text-accent-foreground " "h-10 px-4 py-2"
) { "Actions ▾" }
render Components::UI::DropdownMenuContent.new do
render Components::UI::DropdownMenuItem.new(value: "edit") { "Edit" }
render Components::UI::DropdownMenuSeparator.new
render Components::UI::DropdownMenuSub.new do
render Components::UI::DropdownMenuSubTrigger.new(value: "share") { "Share" }
render Components::UI::DropdownMenuSubContent.new do
render Components::UI::DropdownMenuItem.new(value: "email") { "Email" }
render Components::UI::DropdownMenuItem.new(value: "slack") { "Slack" }
end
end
end
end
Two-level submenu nesting
Submenus can nest arbitrarily deep. The controller walks the DOM to discover each sub's parent machine, so an inner Sub automatically chains to the outer Sub instead of the root.
Edit
Share
Email
Export
PDF
CSV
render Components::UI::DropdownMenu.new do
render Components::UI::DropdownMenuTrigger.new(
class: "inline-flex items-center justify-center rounded-md text-sm font-medium " "border border-input bg-background hover:bg-accent hover:text-accent-foreground " "h-10 px-4 py-2"
) { "Actions ▾" }
render Components::UI::DropdownMenuContent.new do
render Components::UI::DropdownMenuItem.new(value: "edit") { "Edit" }
render Components::UI::DropdownMenuSub.new do
render Components::UI::DropdownMenuSubTrigger.new(value: "share") { "Share" }
render Components::UI::DropdownMenuSubContent.new do
render Components::UI::DropdownMenuItem.new(value: "email") { "Email" }
render Components::UI::DropdownMenuSub.new do
render Components::UI::DropdownMenuSubTrigger.new(value: "export") { "Export" }
render Components::UI::DropdownMenuSubContent.new do
render Components::UI::DropdownMenuItem.new(value: "pdf") { "PDF" }
render Components::UI::DropdownMenuItem.new(value: "csv") { "CSV" }
end
end
end
end
end
end
Checkbox and radio items
Use DropdownMenuCheckboxItem for independent toggles and DropdownMenuRadioGroup + DropdownMenuRadioItem for a mutually-exclusive set. Give the radio group an aria_label: so screen readers announce what the options control.
Appearance
Show sidebar
Show status bar
Theme
Light
Dark
System
render Components::UI::DropdownMenu.new do
render Components::UI::DropdownMenuTrigger.new(
class: "inline-flex items-center justify-center rounded-md text-sm font-medium " "border border-input bg-background hover:bg-accent hover:text-accent-foreground " "h-10 px-4 py-2"
) { "View ▾" }
render Components::UI::DropdownMenuContent.new do
render Components::UI::DropdownMenuLabel.new { "Appearance" }
render Components::UI::DropdownMenuCheckboxItem.new(value: "sidebar", checked: true) { "Show sidebar" }
render Components::UI::DropdownMenuCheckboxItem.new(value: "statusbar") { "Show status bar" }
render Components::UI::DropdownMenuSeparator.new
render Components::UI::DropdownMenuLabel.new { "Theme" }
render Components::UI::DropdownMenuRadioGroup.new(name: "theme", value: "light", aria_label: "Theme") do
render Components::UI::DropdownMenuRadioItem.new(value: "light", name: "theme", checked: true) { "Light" }
render Components::UI::DropdownMenuRadioItem.new(value: "dark", name: "theme") { "Dark" }
render Components::UI::DropdownMenuRadioItem.new(value: "system", name: "theme") { "System" }
end
end
end
Source
app/components/ui/dropdown_menu.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DropdownMenu < Wabi::Base
def initialize(id: nil, open: false, portal: true, **attrs)
@id = id
@open = open
@portal = portal
@attrs = attrs
end
def view_template(&block)
div(
id: @id,
class: "inline-block",
data: {
controller: "wabi--dropdown-menu",
"wabi--dropdown-menu-open-value": @open.to_s,
"wabi--dropdown-menu-portal-value": @portal.to_s,
}
) do
yield if block_given?
end
end
end
end
end
app/components/ui/dropdown_menu_trigger.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DropdownMenuTrigger < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
button(
type: "button",
data: { "wabi--dropdown-menu-target": "trigger" },
class: user_class
) do
yield if block_given?
end
end
end
end
end
app/components/ui/dropdown_menu_content.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DropdownMenuContent < Wabi::Base
variants do
base "z-50 min-w-[8rem] overflow-hidden rounded-md border border-input bg-popover p-1 " \
"text-popover-foreground shadow-md outline-none " \
"transition-opacity duration-150 ease-out motion-reduce:transition-none " \
"data-[state=open]:opacity-100 data-[state=closed]:opacity-0 " \
"data-[state=closed]:pointer-events-none"
end
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
data: { "wabi--dropdown-menu-target": "positioner" },
class: "z-50 pointer-events-none"
) do
div(
data: { "wabi--dropdown-menu-target": "content" },
"data-state": "closed",
inert: true,
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
end
app/components/ui/dropdown_menu_item.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DropdownMenuItem < Wabi::Base
variants do
base "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 " \
"text-sm outline-none transition-colors " \
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground " \
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
end
# `value` identifies the item in the Zag menu state machine; it is what
# the `onSelect({ value })` callback receives, and what determines
# highlight/selection.
def initialize(value:, disabled: false, **attrs)
@value = value
@disabled = disabled
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
user_data = @attrs.delete(:data) || {}
div(
role: "menuitem",
# User data first, component data second -- component keys win on
# collision so callers can freely add things like
# `data: { action: "click->...", foo: "bar" }` without
# overriding the wabi target / value attributes the controller
# relies on.
data: {
**user_data,
"wabi--dropdown-menu-target": "item",
"wabi-value": @value,
"wabi-disabled": @disabled.to_s,
},
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/dropdown_menu_label.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DropdownMenuLabel < Wabi::Base
variants { base "px-2 py-1.5 text-sm font-semibold" }
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(role: "none", class: merge_class(tokens, user_class)) do
yield if block_given?
end
end
end
end
end
app/components/ui/dropdown_menu_separator.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DropdownMenuSeparator < Wabi::Base
variants { base "-mx-1 my-1 h-px bg-muted" }
def initialize(**attrs)
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
div(role: "separator", class: merge_class(tokens, user_class))
end
end
end
end
app/components/ui/dropdown_menu_shortcut.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Cosmetic helper for displaying a keyboard shortcut at the right edge of a
# menu item. The shortcut is purely visual -- wiring the actual keypress
# handler is the caller's responsibility (Stimulus action or Hotkey lib).
class DropdownMenuShortcut < Wabi::Base
variants { base "ml-auto text-xs tracking-widest text-muted-foreground" }
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
span(class: merge_class(tokens, user_class)) do
yield if block_given?
end
end
end
end
end
app/components/ui/dropdown_menu_checkbox_item.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Toggle-style menu item. Renders with `role="menuitemcheckbox"` and toggles
# its own `data-state`/`aria-checked` on click via the parent
# `wabi--dropdown-menu` controller (no extra Stimulus action wiring needed).
class DropdownMenuCheckboxItem < Wabi::Base
variants do
base "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 " \
"text-sm outline-none transition-colors " \
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground " \
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
end
def initialize(value:, checked: false, disabled: false, **attrs)
@value = value
@checked = checked
@disabled = disabled
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
role: "menuitemcheckbox",
"data-state": @checked ? "checked" : "unchecked",
"aria-checked": @checked.to_s,
data: {
"wabi--dropdown-menu-target": "optionItem",
"wabi-value": @value,
"wabi-type": "checkbox",
"wabi-checked": @checked.to_s,
"wabi-disabled": @disabled.to_s,
},
class: merge_class(tokens, user_class)
) do
# Indicator (checkmark). Shown when data-state=checked; the controller
# toggles `hidden` directly on this span each render. Could also be
# done via Tailwind 4 group-data variants -- explicit hidden toggle
# keeps the controller logic uniform.
span(
data: { "wabi--dropdown-menu-target": "optionItemIndicator" },
hidden: !@checked,
class: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center"
) do
raw(safe('<svg aria-hidden="true" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'))
end
yield if block_given?
end
end
end
end
end
app/components/ui/dropdown_menu_radio_group.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Wraps RadioItems so that selecting one auto-unselects the others within
# the same group. `name` identifies the group; each child RadioItem inherits
# this `name` via `<div role="group">` ancestry (the controller reads
# `data-wabi-name` from the radio item ancestor).
class DropdownMenuRadioGroup < Wabi::Base
# aria_label: optional accessible name for the radio group. When provided,
# aria-label is added to the group div so screen readers announce the
# group name (e.g. aria_label: "Theme"). Alternatively, pair a DropdownMenuLabel
# id with aria-labelledby on this element for a visible label association.
def initialize(name:, value: nil, aria_label: nil, **attrs)
@name = name
@value = value
@aria_label = aria_label
@attrs = attrs
end
def view_template(&block)
div(
role: "group",
aria_label: @aria_label,
data: {
"wabi--dropdown-menu-target": "radioGroup",
"wabi-name": @name,
"wabi-value": @value.to_s,
}
) do
yield if block_given?
end
end
end
end
end
app/components/ui/dropdown_menu_radio_item.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Mutually-exclusive menu item. Renders with `role="menuitemradio"` and
# registers itself with its enclosing `DropdownMenuRadioGroup` via the
# `name` argument -- selecting one radio in the same group automatically
# unselects the rest. Initial selection comes from the RadioGroup's
# `value:` matching this item's `value:`.
class DropdownMenuRadioItem < Wabi::Base
variants do
base "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 " \
"text-sm outline-none transition-colors " \
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground " \
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
end
def initialize(value:, name:, checked: false, disabled: false, **attrs)
@value = value
@name = name
@checked = checked
@disabled = disabled
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
user_data = @attrs.delete(:data) || {}
div(
role: "menuitemradio",
"data-state": @checked ? "checked" : "unchecked",
"aria-checked": @checked.to_s,
# User data first, component data second -- component keys win on
# collision so callers can add `data: { action: "click->..." }`
# without clobbering the wabi target attributes the controller
# relies on for state routing.
data: {
**user_data,
"wabi--dropdown-menu-target": "optionItem",
"wabi-value": @value,
"wabi-name": @name,
"wabi-type": "radio",
"wabi-checked": @checked.to_s,
"wabi-disabled": @disabled.to_s,
},
class: merge_class(tokens, user_class)
) do
span(
data: { "wabi--dropdown-menu-target": "optionItemIndicator" },
hidden: !@checked,
class: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center"
) do
raw(safe('<svg aria-hidden="true" class="h-2 w-2 fill-current" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3"/></svg>'))
end
yield if block_given?
end
end
end
end
end
app/components/ui/dropdown_menu_sub.rb
# frozen_string_literal: true
require "date"
require "securerandom"
module Components
module UI
# Submenu wrapper. Marks a nested-menu boundary; the parent
# `wabi--dropdown-menu` controller creates a child Zag menu machine
# for each `sub` target it finds and wires parent.setChild /
# child.setParent. The `class: "contents"` makes this wrapper invisible
# to CSS layout so the sub-trigger renders inline with its menu
# siblings and the sub-content floats independently.
#
# Each instance gets a unique `data-wabi-sub-id` so the controller
# can index machines by id across arbitrary nesting depth (N-level).
class DropdownMenuSub < Wabi::Base
def initialize(**attrs)
@sub_id = "sub-#{SecureRandom.uuid}"
@attrs = attrs
end
def view_template(&block)
div(
data: {
"wabi--dropdown-menu-target": "sub",
"wabi-sub-id": @sub_id,
},
class: "contents"
) do
yield if block_given?
end
end
end
end
end
app/components/ui/dropdown_menu_sub_trigger.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Sub-menu trigger: an item in the parent menu that ALSO opens a
# submenu on hover / arrow-right. The parent controller applies
# `parentApi.getTriggerItemProps(childApi)` to merge item + trigger
# behavior. Rotates via `data-state=open` styling -- Zag toggles the
# data attribute synchronously on hover.
class DropdownMenuSubTrigger < Wabi::Base
variants do
base "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 " \
"text-sm outline-none transition-colors " \
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground " \
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground " \
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
end
def initialize(value:, disabled: false, **attrs)
@value = value
@disabled = disabled
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
role: "menuitem",
"aria-haspopup": "menu",
data: {
"wabi--dropdown-menu-target": "subTrigger",
"wabi-value": @value,
"wabi-disabled": @disabled.to_s,
},
class: merge_class(tokens, user_class)
) do
yield if block_given?
raw(safe('<svg aria-hidden="true" class="ml-auto h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>'))
end
end
end
end
end
app/components/ui/dropdown_menu_sub_content.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Floating sub-content. Mirrors DropdownMenuContent's shape: a positioner
# wrapper + a content child. The parent controller routes Zag props onto
# `subPositioner` / `subContent` instead of `positioner` / `content` so
# the parent menu's own content stays untouched.
class DropdownMenuSubContent < Wabi::Base
variants do
base "z-50 min-w-[8rem] overflow-hidden rounded-md border border-input bg-popover p-1 " \
"text-popover-foreground shadow-md outline-none " \
"transition-opacity duration-150 ease-out motion-reduce:transition-none " \
"data-[state=open]:opacity-100 data-[state=closed]:opacity-0 " \
"data-[state=closed]:pointer-events-none"
end
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
data: { "wabi--dropdown-menu-target": "subPositioner" },
class: "z-50 pointer-events-none"
) do
div(
data: { "wabi--dropdown-menu-target": "subContent" },
"data-state": "closed",
inert: true,
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
end
Accessibility
- role="menu" + role="menuitem" anatomy with aria-haspopup for nested triggers.
- Keyboard nav: ↑/↓ between items, → opens submenu, ← / Esc closes.
- Type-ahead jumps to the first item starting with the typed character.
- Content carries inert when closed — out of tab order + accessibility tree (Zag onOpenChange toggle).
- N-level nesting (v0.7): a sub-inside-a-sub works to arbitrary depth — each sub links to its closest ancestor sub or the root menu.