Navigation Menu
A row of links, some of which open a panel underneath.
src/components/rahti_ui/navigation_menu.rs
Basic
Hover a trigger to open its panel; move along the row to switch.
shadcn's own demo shape: two items that open a panel and one that is just a link. The plain link wears `navigation_menu_trigger_style()` — shadcn exports that for exactly this, because a row where the openers and the links look different reads as a mistake.
The panels are in the served HTML, closed and `hidden`. `hidden` and not opacity: an absolutely positioned panel that is merely transparent still takes the pointer and still holds its links in the tab order.
Plain links
The row without any panels, and the current-page hook.
A row with no panels at all, which is a perfectly ordinary navigation menu and needs none of the script. The middle link is `active`, which writes `data-active="true"` — the hook every `data-[active=true]:` utility in the link's class list reads.
`active` is an attribute the caller sets, not something the component works out. Which link is current depends on the router, and the component has no opinion about routers.
With an indicator
An arrow that follows whichever panel is open.
The indicator is the little arrowhead that slides to the open item. It is optional, it goes inside the list, and the root's script places it — the one measurement in the whole component, and it is two numbers: the open item's offset and its width.
The behaviour
What opens a panel, what closes it, and what the keyboard does.
The panels need no measuring and no placement: the item is `relative` and the panel is `md:absolute top-full`, so CSS puts each one under its own trigger. Everything the script owns is *which one is open*.
A pointer resting on an item opens it after 200ms; leaving the whole menu closes after 300ms. That second number is the one that decides whether the component feels broken — the pointer has to cross the six pixels of `mt-1.5` between a trigger and its panel, and a menu that closed on `pointerleave` would shut every single time.
Moving along the row while something is open switches instantly rather than re-delaying, because a menu that makes you wait 200ms at every neighbour feels stuck. A click toggles, which is what makes the row usable on a touch screen where there is no hover to have.
`Escape` closes and puts focus back on the trigger. The arrow keys move along the row. Tabbing out of an open panel closes it, and so does a click anywhere else — both through a `focusin` and a `click` listener on the document, removed again when the effect tears down.
`data-motion` is not set, and that is deliberate. shadcn's four `data-[motion=from-start]` slide utilities exist to animate one panel out as the next slides in across a *shared* viewport; with a panel per item there is nothing to slide across. The zoom-and-fade in the `group-data-[viewport=false]` half of the class list is the animation that actually applies. The four are kept in the string because they are shadcn's and cost nothing — they simply never match.
The port
Which generation this is, and the one part that is deliberately missing.
The `new-york-v4` registry generation, and for once that is not because the docs page is stale. The page publishes a Base UI rewrite whose every measurement comes from a positioning engine — `Portal` into `Positioner` into `Popup` into `Viewport`, sized with `--positioner-width`, `--popup-height` and `--available-width`. Those are not class strings that can be copied; they are an engine. Its trigger is also the newer Button generation, which would not match this library's Button any more than Pagination's did.
**The shared viewport is not ported.** shadcn's `viewport` prop defaults to true, which renders one box that every panel is *moved into* so it can animate its width and height between panels of different sizes. Radix does that by portalling each content into the viewport and publishing the measured size as a custom property.
Reproducing it here would mean relocating a content node out of its item and into a shared box at open time — moving DOM the server rendered and the runtime's morph still believes it owns. That is a fight this library has already lost twice, and a poor trade for an animation. So this is shadcn's `viewport={false}` shape, which shadcn documents and ships CSS for: the root writes `data-viewport="false"` permanently, and every `group-data-[viewport=false]` utility — the border, the background, the shadow, the radius, the zoom — is live because of it.
Nothing is faked and nothing is half-wired: there is no `NavigationMenuViewport` in the file and no `viewport` prop to set to a value that would not work.