Anatomy of the GodUI Menubar
A row of dropdown menus that grow out of their triggers, and what it takes to let one menu exit while the next one enters.
↓Scroll to step through it
A row of dropdowns
Each menu in the bar is a dropdown menu, and it moves like
Dropdown Menu. Radix publishes the
point nearest the trigger as --radix-menubar-content-transform-origin, so
the godui-popover-in scale grows from there, and the per-side
--godui-enter-x/y variables make the menu drift 4px out of the bar.
MenubarSubContent carries the same classes, so a sub-menu grows out of its
parent item.
Hopping
With one menu open, moving the pointer to the next trigger or pressing an
arrow key opens that menu instead. The old menu gets
data-state="closed" and plays godui-popover-out (150ms) while the new one
plays godui-popover-in (260ms), so the swap reads as one motion.
shadcn's menubar has no exit animation, so its old menu unmounts at once.
Keeping it mounted for 150ms exposed a Radix interaction: the new menu takes
focus, and the old menu, still listening for focus and presses outside
itself, dismisses the whole bar. A closing MenubarContent now ignores
outside interactions; only an open menu can dismiss.
The result
Open File, then slide the pointer to Edit or press the right arrow key. Hover Share or Find to open a sub-menu.
- opacity
- 0 → 1
- scale
- 0.96 → 1
- translate
- 0 -4px → 0 0
<MenubarPrimitive.Contentdata-slot="menubar-content"align={align}alignOffset={alignOffset}sideOffset={sideOffset}className={cn( "z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) … data-[side=bottom]:[--godui-enter-y:-0.25rem] … data-[state=open]:animate-godui-popover-in data-[state=closed]:animate-godui-popover-out", className,)}/>Anatomy of the GodUI Menubar
A row of dropdown menus that grow out of their triggers, and what it takes to let one menu exit while the next one enters.
- opacity
- 0 → 1
- scale
- 0.96 → 1
- translate
- 0 -4px → 0 0
<MenubarPrimitive.Contentdata-slot="menubar-content"align={align}alignOffset={alignOffset}sideOffset={sideOffset}className={cn( "z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) … data-[side=bottom]:[--godui-enter-y:-0.25rem] … data-[state=open]:animate-godui-popover-in data-[state=closed]:animate-godui-popover-out", className,)}/>A row of dropdowns
Each menu in the bar is a dropdown menu, and it moves like
Dropdown Menu. Radix publishes the
point nearest the trigger as --radix-menubar-content-transform-origin, so
the godui-popover-in scale grows from there, and the per-side
--godui-enter-x/y variables make the menu drift 4px out of the bar.
MenubarSubContent carries the same classes, so a sub-menu grows out of its
parent item.
<MenubarPrimitive.Contentdata-slot="menubar-content"ref={composedRef}onInteractOutside={(event) => { onInteractOutside?.(event); // Hopping: this menu stays mounted while it animates out, so focus // landing in (or a press on) the next menu would count as "outside" // and close the whole menubar. A menu that is already closing never // dismisses anything. if (contentRef.current?.dataset.state === "closed") { event.preventDefault(); }}}…/>Hopping
With one menu open, moving the pointer to the next trigger or pressing an
arrow key opens that menu instead. The old menu gets
data-state="closed" and plays godui-popover-out (150ms) while the new one
plays godui-popover-in (260ms), so the swap reads as one motion.
shadcn's menubar has no exit animation, so its old menu unmounts at once.
Keeping it mounted for 150ms exposed a Radix interaction: the new menu takes
focus, and the old menu, still listening for focus and presses outside
itself, dismisses the whole bar. A closing MenubarContent now ignores
outside interactions; only an open menu can dismiss.
The result
Open File, then slide the pointer to Edit or press the right arrow key. Hover Share or Find to open a sub-menu.
What's animated
Check and radio indicators pop in from 50% when toggled, not when a menu opens.
| Interaction | Keyframe / mechanism | Properties | Easing | Duration |
|---|---|---|---|---|
| Open (menu, sub-menu) | godui-popover-in | opacity, scale 0.96 → 1 from the trigger, translate 4px → 0 | ease-spring-snappy | 260ms |
| Close | godui-popover-out | the same, reversed | ease-out-expo | 150ms |
| Hop to the next menu | old menu godui-popover-out, new menu godui-popover-in | as above, both at once | as above | 150ms / 260ms |
| Check / radio appears | godui-fade-scale-in from 50% | opacity, scale | ease-spring-snappy | 260ms |
| Trigger and item highlight | snaps | none | none | none |
Why GPU-only
Menus, sub-menus and indicators animate opacity, scale and translate.
Trigger and item highlights are background changes, so GodUI lets them snap
instead of animating paint.
Reduced motion
--godui-motion: 0 removes the scale and drift from both keyframes. Menus
and their indicators fade, in 150ms, and a hop is a crossfade.
Replacing shadcn
npx shadcn add @godui/menubar overwrites components/ui/menubar.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.
shadcn's MenubarContent has no exit animation, so a menu vanishes the moment
you leave it. GodUI adds one, which means the old menu is still mounted while
the next one opens. MenubarContent therefore ignores outside focus and presses
while it is closing (data-state="closed"); without that, the exiting menu
would treat the next menu as "outside" and close the whole menubar. An open
menu dismisses as before, and your own onInteractOutside,
onFocusOutside and onPointerDownOutside still run. MenubarSubContent
does the same while a sub-menu animates out.