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
| Method | Args | Default | Description |
|---|---|---|---|
sui.dropdown_menu | side:, align:, src:, reload:, loading:, panel:, enabled:, content:, **opts | :bottom / :start | Wrapper; 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:, &block | nil | Interactive 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.sub | side:, align:, content:, &block | :right / :start | Nested menu, same slot shape: sub.trigger(name) renders the chevroned row, the rest of the block is the sub panel. |