ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

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/vanilla

Pin @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

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.

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.

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.