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.
↓Scroll to step through it
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.
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.
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.
<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,)}/>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.
<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,)}/>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.
<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,)}/>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.
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
| Interaction | Keyframe / mechanism | Properties | Easing | Duration |
|---|---|---|---|---|
| Viewport opens | godui-fade-scale-in from 95%, top origin | opacity, scale | ease-spring-snappy | 260ms |
| Viewport closes | godui-fade-scale-out | opacity, scale | ease-out-expo | 150ms |
| Switch item: new content | godui-slide-in-from-right / -left by direction | opacity, translate 3rem → 0 | ease-spring-smooth | 380ms |
| Switch item: old content | godui-slide-out-to-left / -right by direction | opacity, translate 0 → 3rem | ease-out-expo | 260ms |
| Viewport position on switch | glides under the new trigger (centered, clamped inside the menu); first open lands in place | translate | ease-spring-snappy | 260ms |
| Viewport size on switch | snaps | none | none | none |
| Without viewport: open / close | godui-fade-scale-in / -out, top origin | opacity, scale | ease-spring-snappy / ease-out-expo | 260ms / 150ms |
| Chevron | transition | transform (rotate 0 → 180deg) | ease-spring-snappy | 260ms |
| Indicator | transition (position), godui-fade-in / -out (visibility) | transform, opacity | ease-spring-snappy | 260ms |
| Trigger and link highlight | snaps | none | none | none |
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).