GodUIGodUI
129Follow on X

Anatomy of the GodUI Navigation Menu

Content that slides in from the side you moved toward, a viewport that snaps to size instead of tweening it, and the exit Radix forgets to play.

01/03Direction-aware content
tsx
<NavigationMenuPrimitive.Contentdata-slot="navigation-menu-content"className={cn(  "top-0 left-0 w-full p-2 pr-2.5 [--godui-enter-distance:3rem]   data-[motion=from-end]:animate-godui-slide-in-from-right   data-[motion=from-start]:animate-godui-slide-in-from-left   data-[motion=to-end]:animate-godui-slide-out-to-right   data-[motion=to-start]:animate-godui-slide-out-to-left   md:absolute md:w-auto …",  className,)}/>
01

Direction-aware content

When you move from one trigger to another, Radix compares their positions and stamps both contents with a data-motion attribute. Moving right, the content you leave gets to-start and the new one from-end; moving left, to-end and from-start. Each value maps to one shared slide keyframe.

The slide distance is local: --godui-enter-distance: 3rem. shadcn slides 13rem, nearly the whole panel, which reads as a carousel; 3rem is enough to show direction while both contents stay mostly inside the viewport. The new content takes 380ms on the smooth spring, the old one leaves in 260ms on ease-out-expo, so the outgoing panel is gone before the incoming one settles.

Radix currently forgets the outgoing half: it removes the old content at once. GodUI's viewport watches for that removal and puts an inert copy back for the length of its exit, so the slide-out still plays. A content whose exit already started is left alone, so a fixed Radix won't play it twice.

02/03Why the viewport snaps
tsx
<NavigationMenuPrimitive.Viewportdata-slot="navigation-menu-viewport"className={cn(  "relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)]   w-full origin-top overflow-hidden … [--godui-enter-scale:0.95]   data-[state=open]:animate-godui-fade-scale-in   data-[state=closed]:animate-godui-fade-scale-out   md:w-[var(--radix-navigation-menu-viewport-width)]",  className,)}/>
02

Why the viewport snaps

The viewport is a box that Radix sizes to the active content through two CSS variables. Many navigation menus tween that width and height. Each frame of such a tween is a layout pass: the browser recomputes the box, repaints its border and shadow, and redraws the clipped content. On a busy page that is where frames drop.

GodUI lets the size snap. The jump happens in the same frame the new content appears, and the eye follows the content's slide, not the box's edge. The only things that animate are the contents' translate and opacity, and the viewport's own scale and opacity when it opens or closes, all of which the compositor runs without layout.

03/03Result
03

The result

Hover Home, then move to Components and back. Watch which side each content leaves from. The viewport jumps to each panel's size while the contents slide.

What's animated

InteractionKeyframe / mechanismPropertiesEasingDuration
Viewport opensgodui-fade-scale-in from 95%, top originopacity, scaleease-spring-snappy260ms
Viewport closesgodui-fade-scale-outopacity, scaleease-out-expo150ms
Switch item: new contentgodui-slide-in-from-right / -left by directionopacity, translate 3rem → 0ease-spring-smooth380ms
Switch item: old contentgodui-slide-out-to-left / -right by directionopacity, translate 0 → 3remease-out-expo260ms
Viewport position on switchglides under the new trigger (centered, clamped inside the menu); first open lands in placetranslateease-spring-snappy260ms
Viewport size on switchsnapsnonenonenone
Without viewport: open / closegodui-fade-scale-in / -out, top originopacity, scaleease-spring-snappy / ease-out-expo260ms / 150ms
Chevrontransitiontransform (rotate 0 → 180deg)ease-spring-snappy260ms
Indicatortransition (position), godui-fade-in / -out (visibility)transform, opacityease-spring-snappy260ms
Trigger and link highlightsnapsnonenonenone

Radix opens a trigger on hover and toggles it on click, so resting on a trigger and then clicking it closed the menu the hover had just opened. GodUI keeps a hover-opened trigger open on that click; clicking a trigger you opened by clicking still closes it.

shadcn pins the shared viewport to the menu's left edge, so a small panel opens far from its trigger. GodUI centers it under the open trigger, keeping it inside the menu; a panel wider than the room left stays left-aligned, as in shadcn.

Why GPU-only

Contents and the viewport animate opacity, scale and translate; the chevron and the indicator transition transform. The viewport's size and the trigger highlights snap instead of animating layout or paint.

Reduced motion

--godui-motion: 0 removes every slide and scale from the shared keyframes, so switching items becomes a 150ms crossfade and the viewport fades in place. The chevron and indicator transitions are turned off with motion-reduce:transition-none, so they jump, and so does the viewport's glide to a new trigger.

Replacing shadcn

npx shadcn add @godui/navigation-menu overwrites components/ui/navigation-menu.tsx. Exports, props and data-slot attributes match shadcn/ui new-york-v4, so existing imports keep working. It also installs godui-motion (easings, keyframes, the FLIP hook) into your project.

GodUI also works around a Radix bug. Radix currently drops the exit of viewport content: the content you leave is removed at once, so shadcn's slide-out never plays. GodUI's NavigationMenuViewport keeps an inert copy of that content (no ids, aria-hidden, no pointer events) in place until its exit animation ends, then removes it. Once Radix keeps the content mounted through its exit, the copy is skipped, so the exit never plays twice.

That work happens in a small client component, so the install also adds components/ui/navigation-menu-viewport-frame.tsx. navigation-menu.tsx itself stays server-safe like shadcn's (no "use client"), so you can still call navigationMenuTriggerStyle() from a Server Component.

NavigationMenuIndicator slides with the translateX Radix sets inline. Radix measures the trigger's offsetLeft, which is 0 inside shadcn's relative items, so the indicator only moves if you give the items className="static" (viewport mode).