Blocks

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 %>

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" } } %>

API

MethodArgsDefaultDescription
sui.dropdown_menuside:, align:, src:, reload:, loading:, 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.
m.trigger&blockMarks 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.separatorHorizontal divider line between groups of items.
m.item(name)href:, &blocknilInteractive row. Shortcut renders <button> (or <a> when href: 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.