Skip to content

File, Edit, View menus with one pill that slides between them.

Menus & tooltips@base-ui/reactmotion

01Preview

Launch plan· Edited

Launch plan

We ship the new billing flow to 10% of workspaces on Tuesday, then widen to everyone by Friday if refunds stay flat.

Support gets the runbook on Monday. Sales hears about it in the weekly sync, not from customers.

41 words100%

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react motion

03Usage

import {
  Menubar,
  MenubarContent,
  MenubarItem,
  MenubarMenu,
  MenubarSeparator,
  MenubarTrigger,
} from "@/components/ui/menubar";

<Menubar aria-label="Launch plan">
  <MenubarMenu>
    <MenubarTrigger>File</MenubarTrigger>
    <MenubarContent>
      <MenubarItem shortcut="⌘N" onClick={create}>New document</MenubarItem>
      <MenubarItem shortcut="⌘S" onClick={save}>Save</MenubarItem>
      <MenubarSeparator />
      <MenubarItem variant="danger" onClick={trash}>Move to Trash</MenubarItem>
    </MenubarContent>
  </MenubarMenu>
  <MenubarMenu>
    <MenubarTrigger>Edit</MenubarTrigger>
    <MenubarContent>
      <MenubarItem shortcut="⌘Z" onClick={undo}>Undo</MenubarItem>
    </MenubarContent>
  </MenubarMenu>
</Menubar>

04Source

"use client";
import { Menu } from "@base-ui/react/menu";
import { Menubar as Base } from "@base-ui/react/menubar";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { cn } from "@/lib/cn";
import { spring } from "@/lib/motion";
import { DropdownMenuContent, type DropdownMenuContentProps } from "@/components/ui/dropdown-menu";

// Rows, groups, submenus and shortcuts are the dropdown menu's own parts.
export {
  DropdownMenuItem as MenubarItem,
  DropdownMenuLinkItem as MenubarLinkItem,
  DropdownMenuCheckboxItem as MenubarCheckboxItem,
  DropdownMenuRadioGroup as MenubarRadioGroup,
  DropdownMenuRadioItem as MenubarRadioItem,
  DropdownMenuGroup as MenubarGroup,
  DropdownMenuLabel as MenubarLabel,
  DropdownMenuSeparator as MenubarSeparator,
  DropdownMenuSub as MenubarSub,
  DropdownMenuSubTrigger as MenubarSubTrigger,
  DropdownMenuSubContent as MenubarSubContent,
  DropdownMenuShortcut as MenubarShortcut,
} from "@/components/ui/dropdown-menu";

export type MenubarProps = Base.Props;

/**
 * A row of menus. Once one is open, hovering its neighbors opens them instead,
 * and the left and right arrow keys walk the bar.
 */
export function Menubar({ className, children, ...rest }: MenubarProps) {
  const id = useId();
  return (
    <Base
      data-menubar=""
      className={(state) =>
        cn("group/menubar relative flex items-center gap-0.5", typeof className === "function" ? className(state) : className)
      }
      {...rest}
    >
      <LayoutGroup id={id}>{children}</LayoutGroup>
    </Base>
  );
}

export type MenubarMenuProps = Menu.Root.Props;

/** One menu in the bar: a trigger and its content. */
export function MenubarMenu(props: MenubarMenuProps) {
  return <Menu.Root {...props} />;
}

export type MenubarTriggerProps = Menu.Trigger.Props;

/**
 * The menu's name in the bar. The open one sits on a pill that slides to the
 * next menu as the pointer or the arrow keys move along the bar.
 */
export function MenubarTrigger({ className, children, ...rest }: MenubarTriggerProps) {
  const reduce = useReducedMotion();
  return (
    <Menu.Trigger
      className={(state) =>
        cn(
          "relative isolate inline-flex h-7 shrink-0 select-none items-center rounded-md px-2.5 text-[13px] text-fg-2 outline-none",
          "transition-colors duration-150 touch-manipulation [-webkit-tap-highlight-color:transparent]",
          "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
          // Hover is a one-step wash, but only while nothing is open: once a menu is,
          // the pill is the only background on the bar.
          "group-not-data-has-submenu-open/menubar:hover:bg-hover group-not-data-has-submenu-open/menubar:hover:text-fg",
          "data-popup-open:text-fg data-disabled:pointer-events-none data-disabled:opacity-50",
          typeof className === "function" ? className(state) : className,
        )
      }
      render={(props, state) => (
        <button type="button" {...props}>
          {state.open && (
            <motion.span
              aria-hidden
              layoutId="menubar-pill"
              transition={reduce ? { duration: 0 } : spring.snappy}
              className="absolute inset-0 -z-10 rounded-md bg-fg/[0.07]"
            />
          )}
          {props.children}
        </button>
      )}
      {...rest}
    >
      {children}
    </Menu.Trigger>
  );
}

export type MenubarContentProps = DropdownMenuContentProps;

/**
 * The open menu. Its labels start on the same line as the trigger's, and when the
 * bar hands off from one menu to the next the new one appears on the same frame.
 */
export function MenubarContent({ sideOffset = 6, alignOffset = -3, ...rest }: MenubarContentProps) {
  return <DropdownMenuContent side="bottom" align="start" sideOffset={sideOffset} alignOffset={alignOffset} {...rest} />;
}

05Props

Menubar

PropTypeDefaultDescription
aria-labelstringNames the bar, usually after the document or app it controls.
modalbooleantrueBlocks outside pointer events while a menu is open.
loopFocusbooleantrueLeft and Right wrap from the last menu to the first.
orientation"horizontal" | "vertical""horizontal"A vertical bar walks with Up and Down and opens menus to the side.
disabledbooleanfalseDisables every menu in the bar.

MenubarMenu

PropTypeDefaultDescription
openbooleanControlled open state. Use with onOpenChange.
onOpenChange(open: boolean, details) => voidCalled when this menu opens or closes.
disabledbooleanfalseDims this menu's trigger and skips it.

MenubarTrigger

PropTypeDefaultDescription
children*ReactNodeThe menu's name, a single word: File, Edit, View.
classNamestring | (state) => stringMerged last. The open trigger carries data-popup-open.

MenubarContent

PropTypeDefaultDescription
sideOffsetnumber6Gap under the bar.
alignOffsetnumber-3Shifts the menu so its edge sits just outside the trigger's.
containerHTMLElement | RefObject<HTMLElement>Where the menu is portaled. Defaults to the body.

MenubarItem · CheckboxItem · RadioGroup · RadioItem · Sub · SubTrigger · SubContent · Label · Separator · Group · Shortcut

PropTypeDefaultDescription
Dropdown Menu partsRe-exported from Dropdown Menu under Menubar names, with the same props: icon, shortcut, description, variant.

06Notes

Behavior

  • Once a menu is open, hovering another trigger opens that one instead; the bar stays in menu mode until Escape or a click outside.
  • Shortcuts align in one column across every menu row, the key on its own column and modifiers stacked to its left. They are display only.
  • Triggers get a hover wash only while nothing is open, so the bar never shows two backgrounds at once.
  • Rows without icons indent to the icon column automatically when any row in that menu has one.

Motion

  • The first menu grows from its trigger (scale 0.96 and a 4px drop, 180ms expo ease-out).
  • Moving between open menus swaps the popups on the same frame while one pill slides under the triggers on the snappy spring, so the bar reads as a single control.
  • Reduced motion jumps the pill and keeps only fades.

Accessibility

  • Base UI Menubar: role menubar with menuitem triggers and one tab stop for the whole bar.
  • Left and Right move between menus (opening the next one when a menu is already open), Down opens, Escape closes and returns focus to the trigger.
  • Give the bar an aria-label. Shortcuts are exposed as aria-keyshortcuts on each row.