Navigation Menu
The marketing site's primary navigation, with sub-link panels.
Overview
A row of top-level entries where some open a panel of sub-links. It wraps Base UI's
NavigationMenu, so hover intent, the keyboard model (arrow keys across the row, Escape to
close, focus returning to the trigger) and the transition between panels come from a
maintained primitive rather than being rebuilt per campaign.
Nothing here hardcodes Rogo's menu, since you compose the parts and pass your own entries, which is what makes it reusable for a one-off landing page on a subdomain.
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuPanel,
NavigationMenuPanelLink,
NavigationMenuTrigger,
} from "@rogo-technologies/brand/navigation-menu";Anatomy
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Product</NavigationMenuTrigger>
<NavigationMenuContent className="grid w-[420px] gap-1">
<NavigationMenuPanelLink href="/product" title="Platform" description="…" />
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="/customers">Customers</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
<NavigationMenuPanel />
</NavigationMenu>NavigationMenuPanel is required and goes last, as a sibling of the list rather than inside
it. It is the portal, positioner, popup and viewport in one part: Base UI needs all four and
their nesting order is load-bearing, so the wrapper composes them to remove the most common way to
get this wrong. One Panel serves every item; the open item's Content is rendered into it.
Parts
| Part | What it is |
|---|---|
NavigationMenu | Root. Owns open state and hover intent. |
NavigationMenuList | The row. Renders a <ul>. |
NavigationMenuItem | One entry in the row. |
NavigationMenuTrigger | An entry that opens a panel. |
NavigationMenuLink | An entry that just navigates. Stays in the same arrow-key row as triggers. |
NavigationMenuContent | The panel body for one item. Set its width and grid here. |
NavigationMenuPanelLink | A destination inside a panel. sm takes title plus optional description; display is title-only. |
NavigationMenuPanelFeature | A 150px-tall picture with title and supporting line stacked under it: media, title, description. |
NavigationMenuPanel | The shared popup the open item renders into. Required, once, last. |
The panel morphs between items
Moving from one trigger to another keeps the same popup rather than closing and reopening it. An
anchored card resizes and slides to the new trigger; a full panel stays a fixed height and
crossfades its contents. That is not automatic. It needs three things, which the wrapper already
does:
- the popup binds its size to
--popup-width/--popup-height(anchored) or to a fixed height (full); - the viewport is
relative+overflow-hiddenso the outgoing and incoming panels can overlap; Contentstays in flow. Giving itposition: absolutecollapses the measurement to zero and the panel renders as a dot.
Content slides in from the side the pointer came from via data-activation-direction on an
anchored card. A full panel sets --nav-menu-slide to 0, so the same classes become a 200ms
linear fade. data-instant disables the transition when Base UI wants an immediate jump.
Animating size is layout work on every frame, so the anchored popup is deliberately not
promoted to its own compositor layer, since a promoted layer would be re-rasterised on each of
those frames.
Every transition list here names scale / translate / rotate / clip-path rather than
transform. Tailwind v4 compiles those utilities to the standalone CSS properties, so
transition-transform animates nothing and the panel snaps between states while only the opacity
fades, which reads as lag, not as a broken class.
Two panel shapes
NavigationMenuPanel takes a variant:
variant | What it is |
|---|---|
anchored (default) | A floating card under its own trigger. Morphs width and height between items. |
full | Spans the anchor edge to edge, flush under it. Fixed height; contents crossfade. |
A full panel needs an anchor as wide as the page, because the default anchor is the trigger and
the panel would come out the width of one nav label:
const headerRef = useRef<HTMLElement>(null);
<header ref={headerRef}>
<NavigationMenu>
…
<NavigationMenuPanel variant="full" anchor={headerRef} />
</NavigationMenu>
</header>;It carries a 30% black scrim over the page below the header (light and dark chrome alike), and pins
itself with position: fixed so it tracks a sticky header instead of being left at the scroll
offset it opened at. Give its Content w-full p-0 and set the page's own gutters inside, since
the panel is the page's shell from its top edge down, not a card with padding.
Open, Enter, and Leave are separate for the dropdown and the scrim, matching the Framer overlay:
- Dropdown Open / Leave — clip-path wipe
inset(0 0 100% 0)↔inset(0), 400ms,cubic-bezier(0.22, 1, 0.36, 1)(Framerspring-duration 0.4s bounce 0.2). Leave reverses the same wipe; it is not the Appear tween. - Content Enter — Appear, 500ms,
cubic-bezier(0.12, 0.23, 0.5, 1),y: 12, stagger 100ms (same as the header groups' 1.0 / 1.1 / 1.2 delays). - Scrim Open — opacity fade, 400ms,
cubic-bezier(0.5, 0, 0.88, 0.77)(Framer backdrop enter; serialize duration0sinherits overlay Open). - Scrim Leave — opacity fade, 500ms,
cubic-bezier(0.12, 0.23, 0.5, 1)(Framer Appear / backdrop exit).
Enter for the panel's contents (staggered Appear) lives on the consumer, so moving between items
does not replay it. Base UI measures the popup's computed height in a layout effect while
data-starting-style is still on it, so any starting height would be measured as the panel's
real one and lock it there. Clip-path is invisible to that measurement, costs no layout, and
stays interruptible. Moving between items does not resize the box — contents crossfade at that
fixed height (min(320px, 90vh)).
The scrim is a sibling of the popup, not a child, so the wipe does not clip it, and it sits
behind the panel (z-0 / z-10) so the panel paints on top. There is no shadow on the panel: the
header's bottom border is the only edge, and a drop shadow on the wipe would paint a moving line
the clip is already drawing.
The divider above the panel belongs to the header, not to the panel. The panel sits flush
against the header's bottom border, so drawing its own top edge would put two hairlines there.
The website turns the header's border on for an open menu with
has-[[data-popup-open]]:border-marketing-border, read off the open trigger rather than mirrored
into React state.
Idle labels in the row sit at full opacity. Hovering one, focusing one, or opening a panel sets the others to 60%. Opacity is set on the items so enter and leave both interpolate.
Links come in two sizes
NavigationMenuPanelLink takes a size:
size | What it is |
|---|---|
sm (default) | A row in a floating card: 14px title, supporting line, plate behind it on hover. |
display | A single-line destination at type-marketing-display-md (40px). No plate, no description. |
display is what a full panel holds. Hover (or keyboard focus) keeps that link at full ink and
fades the other display links to 40% — the invert lives on the links themselves so leave
interpolates. The feature card is a separate link and is left out of both the trigger and the
fade, so hovering a destination does not dim the picture.
A full panel lays out as three equal columns: two stacks of up to three destinations, then the
feature. The stacks share a top so a short second column does not sink; the third column stays
reserved when there is nothing to preview, so the stacks do not shift.
The chevron is built in
NavigationMenuTrigger renders its own chevron through Base UI's Icon part, so it flips with the
panel and you never pass one:
<NavigationMenuTrigger>Product</NavigationMenuTrigger>Icons come from @rogo-technologies/brand/icons, the same central-icons set the product uses.
Never hand-draw one.
Use a plain link when there is no panel
NavigationMenuLink rather than a bare <a>: Base UI keeps it in the roving-focus row, so arrow
keys move across the entire nav instead of skipping past the entries that have no panel.
Responsive
The component is the desktop row. It has no built-in mobile treatment, because the panels are hover-driven and there is no room for them on a phone. Build the small-screen navigation from Sheet and swap on your breakpoint, which is what the website does.