Dropdown Menu

Displays a list of actions or links that appear below a trigger button. The panel renders in the browser's top layer, so no container can clip it.

Installation

bin/rails g shadcnrb:component dropdown_menu

Usage

The block is the menu; m.trigger marks what opens it — you write that element yourself with any helper, the same shape as sui.tooltip and sui.hover_card. Open/close behaviour is handled by the bundled Stimulus controller.

<%= sui.dropdown_menu do |m| %>
  <%= m.trigger { sui.button "Open menu", variant: :outline } %>
  <%= m.label "My Account" %>
  <%= m.separator %>
  <%= m.item "Profile",  icon: :user %>
  <%= m.item "Settings", icon: :settings %>
  <%= m.separator %>
  <%= m.item "Sign out", icon: :x %>
<% end %>

With links

Pass href: to m.item to render an <a> tag instead of a button.

<%= sui.dropdown_menu do |m| %>
  <%= m.trigger { sui.button "Navigate" } %>
  <%= m.item "Dashboard", href: "#" %>
  <%= m.item "Billing",   href: "#" %>
  <%= m.separator %>
  <%= m.item "Log out",   href: "#" %>
<% end %>

Non-GET items

Pass method: and the item renders a CSRF-safe button_to form. icon: works the same as on link items. This one hits the demo delete endpoint and shows a toast.

<%= sui.dropdown_menu do |m| %>
  <%= m.trigger { sui.button "Chart actions", variant: :outline } %>
  <%= m.item "Open", "#", icon: :folder %>
  <%= m.separator %>
  <%= m.item "Delete chart", destroy_demo_chart_path(2), method: :delete, icon: :x %>
<% end %>

Sides and alignment

side: / align: place the panel (defaults :bottom / :start); it flips to the opposite side when the viewport runs out of room. Panel options ride in content:.

<%= sui.dropdown_menu align: :end, content: { class: "w-44" } do |m| %>
  <%= m.trigger { sui.button "Aligned end", variant: :outline } %>
  <%= m.item "Profile", "#" %>
  <%= m.item "Sign out", "#" %>
<% end %>

Nested sub-menu

m.sub do |sub| spawns a nested menu with the same slot shape: sub.trigger is a regular-looking item with a right-chevron, and the rest of the block is the sub panel, floated to the right (flipping left near the viewport edge). Opens on hover, closes after a short delay when the pointer leaves both. Clicking a leaf item cascades-closes every ancestor.

<%= sui.dropdown_menu do |m| %>
  <%= m.trigger { sui.button "File", variant: :outline } %>
  <%= m.item "New file",  "#" %>
  <%= m.item "Open…",     "#" %>
  <%= m.sub do |sub| %>
    <%= sub.trigger "Share" %>
    <%= m.item "Email link", "#" %>
    <%= m.item "Copy link",  "#" %>
    <%= m.sub do |deeper| %>
      <%= deeper.trigger "More…" %>
      <%= m.item "Slack",   "#" %>
      <%= m.item "Discord", "#" %>
    <% end %>
  <% end %>
  <%= m.separator %>
  <%= m.item "Save",     "#" %>
  <%= m.item "Save as…", "#" %>
<% end %>

Composition — scoped helpers inside

Pass a block with a scope arg (m.item do |i|) and use i.link_to / i.button_to inside. The item's scope tells link_to / button_to they're in a non-top-level context, so they default to variant: :bare automatically — no stacked button visuals over the row styling. CSS on m.item ([&>a]:flex-1 [&>form]:flex-1) makes the inner element fill the row so clicks land anywhere.

<%= sui.dropdown_menu do |m| %>
  <%= m.trigger { sui.button "Account" } %>
  <%= m.item do |i| %>
    <%= i.link_to "Profile", "#", icon: :user %>
  <% end %>
  <%= m.item do |i| %>
    <%= i.link_to "Settings", "#", icon: :settings %>
  <% end %>
  <%= m.separator %>
  <%= m.item do |i| %>
    <%= i.button_to "Sign out", "#", method: :delete, icon: :x %>
  <% end %>
<% end %>

Lazy-loaded items

Pass src: and the menu fetches its items on first open via a Turbo Frame — handy for per-row action menus in long tables. Same options as sui.dialog: reload: re-fetches every open, loading: (or the block body after the trigger) is the loading state. The lazy partial reaches m.item etc. through sui.dropdown_menu_proxy, and the same shape works as a kwarg: sui.button icon: :ellipsis, dropdown_menu: { src: ... }.

<%= sui.dropdown_menu src: lazy_dropdown_items_path, content: { class: "w-48" } do |m| %>
  <%= m.trigger { sui.button "Row actions", variant: :outline } %>
  <div class="grid gap-1" aria-hidden="true">
    <%= sui.skeleton class: "h-8" %>
    <%= sui.skeleton class: "h-8" %>
    <%= sui.skeleton class: "h-8" %>
  </div>
<% end %>

<%= sui.button icon: :ellipsis, variant: :ghost, "aria-label": "More",
      dropdown_menu: { src: lazy_dropdown_items_path, content: { class: "w-48" } } %>

Search inside the menu

A search box at the top of the panel needs no JavaScript to take focus: give the input a plain autofocus attribute. Browsers skip autofocus inside a closed popover at page load, so it never steals focus; it acts when the panel opens (the Popover API's focusing steps on showPopover()) or, for a lazy menu, when Turbo renders the frame that contains it.

<!-- Inline: the input is in the page, focused when the panel opens. -->
<%= sui.dropdown_menu content: { class: "w-56" } do |m| %>
  <%= m.trigger { sui.button "Assign to…", variant: :outline } %>
  <div class="p-1">
    <%= sui.input placeholder: "Find…", autofocus: true, class: "h-8" %>
  </div>
  <%= m.separator %>
  <%= m.item "Ada",  "#" %>
  <%= m.item "Rin",  "#" %>
  <%= m.item "Joel", "#" %>
<% end %>

<!-- Lazy: the input arrives with the frame, focused when it renders. -->
<%= sui.dropdown_menu src: lazy_dropdown_search_path, content: { class: "w-56" } do |m| %>
  <%= m.trigger { sui.button "Move to project…", variant: :outline } %>
  <div class="grid gap-1" aria-hidden="true">
    <%= sui.skeleton class: "h-8" %>
    <%= sui.skeleton class: "h-8" %>
  </div>
<% end %>

Adopting an existing element as the panel

panel: is the third way to supply the panel, next to the inline block and src:: the id of an element rendered elsewhere, usually inside the block. While enabled: holds (the default) the controller takes it over as a top-layer popover with the panel look and placement; with enabled: false it renders inline again and the trigger does nothing. Flip the data-shadcnrb--dropdown-menu--component-enabled-value attribute to switch at runtime. sui.hover_card takes the same option for hover-opened panels — that is how the sidebar shows a collapsed group's sub-items as a flyout from the icon rail.

sui.collapsible open: true, content: { id: "docs" },
                dropdown_menu: { panel: "docs", side: :right, align: :start } do |c|
  c.trigger { sui.button "Docs", variant: :ghost }
  c.content do
    # rendered inline while enabled: false, as the flyout otherwise
  end
end

API

MethodArgsDefaultDescription
sui.dropdown_menuside:, align:, src:, reload:, loading:, panel:, enabled:, content:, **opts:bottom / :startWrapper; the block body is the menu panel. Mounts the shadcnrb--dropdown-menu--component controller (a subclass of the shared anchored engine) and owns the click/Esc wiring. **opts land on the wrapper, content: on the panel. src: lazy-loads the items via Turbo Frame. panel: adopts an element by id as the panel while enabled: (default true) holds; only content: { class: } applies then.
m.trigger&block—Marks which part of the block is the trigger; everything else is the panel. Your markup, verbatim — click-to-toggle is bound on the wrapper, so the element keeps its own behaviour. Hoisted to the top of the wrapper, so its position in the block doesn't matter.
m.label(name)——Non-interactive section heading inside the panel.
m.separator——Horizontal divider line between groups of items.
m.item(name)href:, method:, icon:, &blocknilInteractive row. Shortcut renders <button> (or <a> when href: is given, or a button_to form when method: is given). Block form renders a <div role="menuitem"> wrapper — put link_to / button_to inside. Closes menu on click.
m.subside:, align:, content:, &block:right / :startNested menu, same slot shape: sub.trigger(name) renders the chevroned row, the rest of the block is the sub panel.