Navigation Menu
A site-navigation bar with links and optional dropdown panels.
Installation
Included in poetry-ui — available as
poetry_navigation_menu
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add navigation-menu
Default
<div class="flex min-h-64 justify-center p-4">
<%= poetry_navigation_menu(label: "Main") do |nav| %>
<% nav.with_item("Products", value: "products") do %>
<div class="grid w-64 gap-1">
<%= poetry_navigation_menu_link(href: "#", active: true) { "Analytics" } %>
<%= poetry_navigation_menu_link(href: "#") { "Automation" } %>
<%= poetry_navigation_menu_link(href: "#") { "Reports" } %>
</div>
<% end %>
<% nav.with_link("Pricing", href: "#") %>
<% nav.with_link("Docs", href: "#") %>
<% end %>
</div>
Viewport
<div class="flex min-h-72 justify-center p-4">
<%= poetry_navigation_menu(label: "Main", viewport: true) do |nav| %>
<% nav.with_item("Products", value: "products") do %>
<div class="grid w-64 gap-1">
<%= poetry_navigation_menu_link(href: "#", active: true) { "Analytics" } %>
<%= poetry_navigation_menu_link(href: "#") { "Automation" } %>
<%= poetry_navigation_menu_link(href: "#") { "Reports" } %>
</div>
<% end %>
<% nav.with_item("Company", value: "company") do %>
<div class="grid w-96 grid-cols-2 gap-1">
<%= poetry_navigation_menu_link(href: "#") { "About" } %>
<%= poetry_navigation_menu_link(href: "#") { "Careers" } %>
<%= poetry_navigation_menu_link(href: "#") { "Press" } %>
<%= poetry_navigation_menu_link(href: "#") { "Contact" } %>
</div>
<% end %>
<% nav.with_link("Docs", href: "#") %>
<% end %>
</div>
With descriptions
<%# Rich panel: each link stacks a title over a short description. Title +
description grids want viewport: true - the shared morphing card sizes them. %>
<div class="flex min-h-72 justify-center p-4">
<%= poetry_navigation_menu(label: "Platform", viewport: true) do |nav| %>
<% nav.with_item("Platform", value: "platform") do %>
<div class="grid w-[26rem] grid-cols-2 gap-1 p-2">
<%= poetry_navigation_menu_link(href: "#", active: true) do %>
<div class="space-y-0.5">
<div class="text-sm font-medium">Identity</div>
<p class="text-xs text-muted-foreground">Sign-in, sessions, and single sign-on in one place.</p>
</div>
<% end %>
<%= poetry_navigation_menu_link(href: "#") do %>
<div class="space-y-0.5">
<div class="text-sm font-medium">Payments</div>
<p class="text-xs text-muted-foreground">Charge, invoice, and reconcile without leaving the app.</p>
</div>
<% end %>
<%= poetry_navigation_menu_link(href: "#") do %>
<div class="space-y-0.5">
<div class="text-sm font-medium">Messaging</div>
<p class="text-xs text-muted-foreground">Email, SMS, and push from a single delivery pipeline.</p>
</div>
<% end %>
<%= poetry_navigation_menu_link(href: "#") do %>
<div class="space-y-0.5">
<div class="text-sm font-medium">Storage</div>
<p class="text-xs text-muted-foreground">Durable object buckets with signed, expiring links.</p>
</div>
<% end %>
</div>
<% end %>
<% nav.with_link("Pricing", href: "#") %>
<% nav.with_link("Docs", href: "#") %>
<% end %>
</div>
With icon
<%# Panel links pair a poetry_icon with the label - the link is already
flex items-center, so a class: "gap-2" override spaces the two. %>
<div class="flex min-h-64 justify-center p-4">
<%= poetry_navigation_menu(label: "Account") do |nav| %>
<% nav.with_item("Account", value: "account") do %>
<div class="grid w-56 gap-1">
<%= poetry_navigation_menu_link(href: "#", active: true, class: "gap-2") do %>
<%= poetry_icon(name: :user) %><span>Profile</span>
<% end %>
<%= poetry_navigation_menu_link(href: "#", class: "gap-2") do %>
<%= poetry_icon(name: :settings) %><span>Preferences</span>
<% end %>
<%= poetry_navigation_menu_link(href: "#", class: "gap-2") do %>
<%= poetry_icon(name: :"credit-card") %><span>Billing</span>
<% end %>
<%= poetry_navigation_menu_link(href: "#", class: "gap-2") do %>
<%= poetry_icon(name: :inbox) %><span>Notifications</span>
<% end %>
</div>
<% end %>
<% nav.with_link("Pricing", href: "#") %>
<% nav.with_link("Support", href: "#") %>
<% end %>
</div>
API
Poetry::Ui::NavigationMenu::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| label: | String | required | The nav landmark's accessible name - a page may hold more than one nav. |
| viewport: | Boolean | defaults to false |
Opts into the shared morphing viewport: panels adopt into one positioned card that morphs size and position between triggers. Off, each panel opens under its own item (also the no-JS shape). |
Slots
| Writer | Description |
|---|---|
| with_item | The bar entries. with_item(title, value:) { panel } declares a trigger + panel; with_item(title, href:) a top-level link (with_link is the shorthand). |
Methods
| Method | Description |
|---|---|
| #with_link(title, href:) | Declares a top-level destination link - shorthand for with_item(title, href:). |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=navigation-menu] | The <nav> landmark around the whole disclosure bar |
| [data-slot=navigation-menu-list] | The bar row holding every item |
| [data-slot=navigation-menu-item] | One bar entry - wraps a trigger + panel pair or a top-level link |
| [data-slot=navigation-menu-trigger] | The disclosure button opening its panel |
| [data-slot=navigation-menu-content] | One item's panel - presence-animated; in viewport mode it is adopted into the shared viewport on first activation |
| [data-slot=navigation-menu-positioner] | The viewport-mode shell popper positions against the active trigger |
| [data-slot=navigation-menu-popup] | The morphing card inside the positioner - open state and the size transition ride here |
| [data-slot=navigation-menu-viewport] | The adoption container inside the popup - adopted panels stack absolutely in it |
| [data-slot=navigation-menu-link] | A REAL destination link - top-level (with_link) or a panel entry (poetry_navigation_menu_link) |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| navigation-menu | data-viewport | the mode marker ("true" = shared morphing viewport, "false" = per-item panels) - the dictionary's group-data chrome keys on it | — |
| navigation-menu-item | data-value | the entry's value - the controller's open/close key | — |
| navigation-menu-trigger | data-popup-open | its panel is open (written with aria-expanded - the chevron rotation hook) | — |
| navigation-menu-trigger | data-open | its panel is open (the controller writes both vocabularies) | — |
| navigation-menu-trigger | data-closed | its panel is closed (written after the first close) | — |
| navigation-menu-content | data-open | panel is open (presence flips the pair at runtime) | — |
| navigation-menu-content | data-closed | panel is closed or animating out (the server-rendered state) | — |
| navigation-menu-content | data-activation-direction | which way the activation traveled between triggers (left/right, viewport mode) - keys the slide styles | — |
| navigation-menu-content | data-viewport-panel | stamped once the panel is adopted into the shared viewport | — |
| navigation-menu-positioner | data-instant | suppresses the morph transitions for one painted frame (cold opens) | — |
| navigation-menu-popup | data-open | a panel is showing (the controller flips the pair) | — |
| navigation-menu-popup | data-closed | the popup is closed (the server-rendered state) | — |
| navigation-menu-popup | data-instant | suppresses the morph transitions for one painted frame (cold opens) | — |
| navigation-menu-popup | data-starting-style | the enter transition's first frame (the presence module's two-frame trick) | — |
| navigation-menu-popup | data-ending-style | held through the exit transition before the popup hides | — |
| navigation-menu-link | data-active | the current page (active: true) | — |
CSS variables
| Part | Variable | Description |
|---|---|---|
| navigation-menu-positioner | --positioner-width | the pinned morph width (reset to auto once the transition settles) |
| navigation-menu-positioner | --positioner-height | the pinned morph height (reset to auto once the transition settles) |
| navigation-menu-popup | --popup-width | the pinned morph width (reset to auto once the transition settles) |
| navigation-menu-popup | --popup-height | the pinned morph height (reset to auto once the transition settles) |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--navigation-menu | registers · keydown on keydown · focusLeft on focusout |
| root | poetry--core--popper (if viewport) | registers · value side · value align · value side_offset · value strategy |
| item | poetry--core--navigation-menu | scheduleOpen on pointerenter · scheduleClose on pointerleave |
| trigger | poetry--core--navigation-menu | toggle on click |
| positioner | poetry--core--popper | target content |
| positioner | poetry--core--navigation-menu | cancelClose on pointerenter · scheduleClose on pointerleave |