A desktop-style bar of menus with one highlight that slides between open menus and instant switching as you sweep across.
import { KbdGroup } from "@/components/ui/kbd"
import {
Menubar,
MenubarCheckboxItem,
MenubarContent,
MenubarGroup,
MenubarItem,
MenubarLabel,
MenubarMenu,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSeparator,
MenubarShortcut,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from "@/components/ui/menubar"
export function MenubarDemo() {
return (
<Menubar aria-label="Editor">
<MenubarMenu>
<MenubarTrigger>File</MenubarTrigger>
<MenubarContent>
<MenubarItem>
New tab
<MenubarShortcut>
<KbdGroup keys="mod+t" variant="flat" size="sm" />
</MenubarShortcut>
</MenubarItem>
<MenubarItem>
New window
<MenubarShortcut>
<KbdGroup keys="mod+n" variant="flat" size="sm" />
</MenubarShortcut>
</MenubarItem>
<MenubarItem disabled>New incognito window</MenubarItem>
<MenubarSeparator />
<MenubarSub>
<MenubarSubTrigger>Share</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem>Email link</MenubarItem>
<MenubarItem>Messages</MenubarItem>
<MenubarItem>Notes</MenubarItem>
</MenubarSubContent>
</MenubarSub>
<MenubarSeparator />
<MenubarItem>
Print…
<MenubarShortcut>
<KbdGroup keys="mod+p" variant="flat" size="sm" />
</MenubarShortcut>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem>
Undo
<MenubarShortcut>
<KbdGroup keys="mod+z" variant="flat" size="sm" />
</MenubarShortcut>
</MenubarItem>
<MenubarItem>
Redo
<MenubarShortcut>
<KbdGroup keys="mod+shift+z" variant="flat" size="sm" />
</MenubarShortcut>
</MenubarItem>
<MenubarSeparator />
<MenubarSub>
<MenubarSubTrigger>Find</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem>Search the web</MenubarItem>
<MenubarSeparator />
<MenubarItem>Find…</MenubarItem>
<MenubarItem>Find next</MenubarItem>
<MenubarItem>Find previous</MenubarItem>
</MenubarSubContent>
</MenubarSub>
<MenubarSeparator />
<MenubarItem>Cut</MenubarItem>
<MenubarItem>Copy</MenubarItem>
<MenubarItem>Paste</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>View</MenubarTrigger>
<MenubarContent>
<MenubarCheckboxItem>Always show bookmarks bar</MenubarCheckboxItem>
<MenubarCheckboxItem defaultChecked>
Always show full URLs
</MenubarCheckboxItem>
<MenubarSeparator />
<MenubarItem inset>
Reload
<MenubarShortcut>
<KbdGroup keys="mod+r" variant="flat" size="sm" />
</MenubarShortcut>
</MenubarItem>
<MenubarItem inset disabled>
Force reload
<MenubarShortcut>
<KbdGroup keys="mod+shift+r" variant="flat" size="sm" />
</MenubarShortcut>
</MenubarItem>
<MenubarSeparator />
<MenubarItem inset>Toggle full screen</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>Profiles</MenubarTrigger>
<MenubarContent>
<MenubarGroup>
<MenubarLabel>Switch profile</MenubarLabel>
<MenubarRadioGroup defaultValue="mira">
<MenubarRadioItem value="jun">Jun</MenubarRadioItem>
<MenubarRadioItem value="mira">Mira</MenubarRadioItem>
<MenubarRadioItem value="sol">Sol</MenubarRadioItem>
</MenubarRadioGroup>
</MenubarGroup>
<MenubarSeparator />
<MenubarItem inset>Edit…</MenubarItem>
<MenubarItem inset>Add profile…</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/menubar.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/menubar.tsx "use client" import * as React from "react" import { Menu as MenuPrimitive } from "@base-ui/react/menu" import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar" import { cn } from "cn" import { useSlidingHighlight } from "@/lib/motion" import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, type DropdownMenuCheckboxItemProps, type DropdownMenuContentProps, type DropdownMenuItemProps, type DropdownMenuLabelProps, type DropdownMenuRadioItemProps, type DropdownMenuSubTriggerProps, } from "@/components/ui/dropdown-menu" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } type MenubarProps = MenubarPrimitive.Props function Menubar({ className, children, ref, ...props }: MenubarProps) { const barRef = React.useRef<HTMLDivElement | null>(null) const highlightRef = React.useRef<HTMLSpanElement | null>(null) useSlidingHighlight( barRef, highlightRef, "[data-slot=menubar-trigger][data-popup-open]" ) const setRef = React.useCallback( (node: HTMLDivElement | null) => { barRef.current = node if (typeof ref === "function") { return ref(node) } if (ref) { ref.current = node } return undefined }, [ref] ) return ( <MenubarPrimitive ref={setRef} data-slot="menubar" className={mergeClassName( "group/menubar relative isolate flex h-9 w-fit max-w-full [scrollbar-width:none] items-center gap-0.5 overflow-x-auto overscroll-none rounded-lg bg-background p-1 ring-(length:--hairline) ring-border [--menubar-radius:var(--radius-lg)] [--menubar-trigger-radius:max(calc(var(--radius-sm)*0.5),calc(var(--menubar-radius)-0.25rem))] data-[orientation=vertical]:h-auto data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch forced-colors:border pointer-coarse:h-11", className )} {...props} > <span ref={highlightRef} aria-hidden="true" data-slot="menubar-highlight" className="pointer-events-none absolute top-0 -z-1 rounded-(--menubar-trigger-radius) bg-accent opacity-0 transition-[transform,width,height,opacity] duration-200 ease-out-quint data-instant:transition-opacity data-visible:opacity-100 motion-reduce:transition-opacity" /> {children} </MenubarPrimitive> ) } function MenubarMenu<Payload>(props: MenuPrimitive.Root.Props<Payload>) { return <DropdownMenu {...props} /> } function MenubarTrigger<Payload>({ className, children, ...props }: MenuPrimitive.Trigger.Props<Payload>) { const iconOnly = React.Children.count(children) === 1 && React.isValidElement(children) && (typeof children.type !== "string" || children.type === "svg") return ( <MenuPrimitive.Trigger data-slot="menubar-trigger" data-icon-only={iconOnly ? "" : undefined} className={mergeClassName( "flex h-7 shrink-0 items-center gap-1.5 rounded-(--menubar-trigger-radius) px-2.5 text-sm font-medium whitespace-nowrap outline-none select-none focus-visible:ring-3 focus-visible:inset-ring-(length:--hairline) focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden not-data-icon-only:has-[>svg:first-child]:ps-2 data-icon-only:aspect-square data-icon-only:justify-center data-icon-only:px-0 data-popup-open:text-accent-foreground pointer-coarse:h-9 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>svg:not([class*='text-'])]:text-muted-foreground data-popup-open:[&>svg:not([class*='text-'])]:text-accent-foreground [@media(hover:hover)]:group-not-data-has-submenu-open/menubar:hover:bg-muted/70", className )} {...props} > {children} </MenuPrimitive.Trigger> ) } function MenubarContent({ className, align = "start", sideOffset = 8, alignOffset = -4, ...props }: DropdownMenuContentProps) { return ( <DropdownMenuContent data-slot="menubar-content" align={align} sideOffset={sideOffset} alignOffset={alignOffset} className={mergeClassName( "min-w-48 data-[instant=group]:transition-none", className )} {...props} /> ) } function MenubarPortal(props: MenuPrimitive.Portal.Props) { return <DropdownMenuPortal data-slot="menubar-portal" {...props} /> } function MenubarGroup(props: MenuPrimitive.Group.Props) { return <DropdownMenuGroup data-slot="menubar-group" {...props} /> } function MenubarLabel(props: DropdownMenuLabelProps) { return <DropdownMenuLabel data-slot="menubar-label" {...props} /> } function MenubarItem(props: DropdownMenuItemProps) { return <DropdownMenuItem data-slot="menubar-item" {...props} /> } function MenubarCheckboxItem(props: DropdownMenuCheckboxItemProps) { return ( <DropdownMenuCheckboxItem data-slot="menubar-checkbox-item" {...props} /> ) } function MenubarRadioGroup(props: MenuPrimitive.RadioGroup.Props) { return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} /> } function MenubarRadioItem(props: DropdownMenuRadioItemProps) { return <DropdownMenuRadioItem data-slot="menubar-radio-item" {...props} /> } function MenubarSeparator(props: MenuPrimitive.Separator.Props) { return <DropdownMenuSeparator data-slot="menubar-separator" {...props} /> } function MenubarShortcut(props: React.ComponentProps<"kbd">) { return <DropdownMenuShortcut data-slot="menubar-shortcut" {...props} /> } function MenubarSub(props: MenuPrimitive.SubmenuRoot.Props) { return <DropdownMenuSub {...props} /> } function MenubarSubTrigger(props: DropdownMenuSubTriggerProps) { return <DropdownMenuSubTrigger data-slot="menubar-sub-trigger" {...props} /> } function MenubarSubContent(props: DropdownMenuContentProps) { return <DropdownMenuSubContent data-slot="menubar-sub-content" {...props} /> } export { Menubar, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarLabel, MenubarMenu, MenubarPortal, MenubarRadioGroup, MenubarRadioItem, MenubarSeparator, MenubarShortcut, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, } export type { MenubarProps }components/ui/dropdown-menu.tsx "use client" import * as React from "react" import { DirectionProvider, useDirection, type TextDirection, } from "@base-ui/react/direction-provider" import { Menu as MenuPrimitive } from "@base-ui/react/menu" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { IconCheck, IconChevronRight, IconCircleFilled, } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { SafeAreaOverlay } from "@/components/ui/safe-area" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } type DropdownMenuContextValue = { direction: TextDirection chosen: string | null choose: (id: string) => void } const DropdownMenuContext = React.createContext<DropdownMenuContextValue | null>(null) const DropdownMenuGroupContext = React.createContext(false) function useDropdownMenuContext(part: string) { const context = React.useContext(DropdownMenuContext) if (!context) { throw new Error(`${part} must be used within <DropdownMenu>.`) } return context } function readDirection(element: Element | null | undefined) { if (!element || !element.isConnected) { return undefined } return getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr" } function getSubmenuElements() { const triggers = document.querySelectorAll( "[data-slot=dropdown-menu-sub-trigger][data-popup-open]" ) const reference = triggers[triggers.length - 1] const id = reference?.getAttribute("aria-controls") const floating = id ? document.getElementById(id) : null return reference && floating ? { reference, floating } : null } type DropdownMenuProps<Payload> = MenuPrimitive.Root.Props<Payload> & { showSafeArea?: boolean } function DropdownMenu<Payload>({ onOpenChange, onOpenChangeComplete, showSafeArea = false, children, ...props }: DropdownMenuProps<Payload>) { const [open, setOpen] = React.useState(false) const inherited = useDirection() const [triggerDirection, setTriggerDirection] = React.useState<TextDirection>() const [chosen, setChosen] = React.useState<string | null>(null) const direction = triggerDirection === "rtl" ? "rtl" : inherited const context = React.useMemo( () => ({ direction, chosen, choose: setChosen }), [direction, chosen] ) return ( <DropdownMenuContext.Provider value={context}> <DirectionProvider direction={direction}> <MenuPrimitive.Root onOpenChange={(open, eventDetails) => { onOpenChange?.(open, eventDetails) if (!eventDetails.isCanceled) { setOpen(open) } if (open && !eventDetails.isCanceled) { setChosen(null) if (eventDetails.trigger) { setTriggerDirection(readDirection(eventDetails.trigger)) } } }} onOpenChangeComplete={(open) => { onOpenChangeComplete?.(open) if (!open) { setChosen(null) } }} {...props} > {children} </MenuPrimitive.Root> {showSafeArea && (props.open ?? open) ? ( <SafeAreaOverlay getElements={getSubmenuElements} /> ) : null} </DirectionProvider> </DropdownMenuContext.Provider> ) } function DropdownMenuTrigger<Payload>( props: MenuPrimitive.Trigger.Props<Payload> ) { return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} /> } function DropdownMenuPortal(props: MenuPrimitive.Portal.Props) { return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> } type DropdownMenuContentProps = MenuPrimitive.Popup.Props & Pick< MenuPrimitive.Positioner.Props, | "side" | "align" | "sideOffset" | "alignOffset" | "anchor" | "collisionPadding" | "collisionAvoidance" | "sticky" > & { portalProps?: Omit<MenuPrimitive.Portal.Props, "children"> } const dropdownMenuContentClassName = "relative max-h-(--available-height) max-w-[min(var(--available-width),20rem)] min-w-40 origin-(--transform-origin) overflow-x-hidden overflow-y-auto overscroll-none rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-(length:--hairline) forced-colors:border ring-foreground/10 transition-[opacity,scale] duration-150 ease-out-quint outline-none focus-visible:outline-hidden [--dropdown-menu-item-radius:max(calc(var(--radius-sm)*0.5),calc(var(--radius-lg)-0.25rem))] data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:data-instant:transition-none motion-safe:data-starting-style:scale-96 motion-safe:data-[chosen]:data-ending-style:delay-120 motion-reduce:transition-opacity" const SIDE_COLLISION_AVOIDANCE = { fallbackAxisSide: "end" } as const function DropdownMenuPopup({ slot, className, side, align, sideOffset, alignOffset, anchor, collisionPadding = 8, collisionAvoidance, sticky, portalProps, ...props }: DropdownMenuContentProps & { slot: string }) { const { direction, chosen } = useDropdownMenuContext( slot === "dropdown-menu-content" ? "DropdownMenuContent" : "DropdownMenuSubContent" ) return ( <MenuPrimitive.Portal {...portalProps}> <MenuPrimitive.Positioner data-slot={slot.replace("content", "positioner")} dir={direction === "rtl" ? "rtl" : undefined} side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset} anchor={anchor} collisionPadding={collisionPadding} collisionAvoidance={ collisionAvoidance ?? (side === "bottom" || side === "top" ? undefined : SIDE_COLLISION_AVOIDANCE) } sticky={sticky} className="isolate z-50 outline-none focus-visible:outline-hidden" > <MenuPrimitive.Popup data-slot={slot} data-chosen={chosen === null ? undefined : ""} className={mergeClassName( cn( dropdownMenuContentClassName, slot === "dropdown-menu-content" && "min-w-[max(var(--anchor-width),10rem)]" ), className )} {...props} /> </MenuPrimitive.Positioner> </MenuPrimitive.Portal> ) } function DropdownMenuContent({ side = "bottom", align = "start", sideOffset = 4, alignOffset = 0, ...props }: DropdownMenuContentProps) { return ( <DropdownMenuPopup slot="dropdown-menu-content" side={side} align={align} sideOffset={sideOffset} alignOffset={alignOffset} {...props} /> ) } function DropdownMenuGroup(props: MenuPrimitive.Group.Props) { return ( <DropdownMenuGroupContext.Provider value> <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> </DropdownMenuGroupContext.Provider> ) } type DropdownMenuLabelProps = MenuPrimitive.GroupLabel.Props & { inset?: boolean } const dropdownMenuLabelClassName = "px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground wrap-anywhere data-inset:ps-8" function DropdownMenuStandaloneLabel({ className, render, ...props }: MenuPrimitive.GroupLabel.Props) { const resolved = mergeClassName(dropdownMenuLabelClassName, className) return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: typeof resolved === "function" ? resolved({}) : resolved, }, props as React.ComponentProps<"div"> ), }) } function DropdownMenuLabel({ inset, ...props }: DropdownMenuLabelProps) { const inGroup = React.useContext(DropdownMenuGroupContext) const Label = inGroup ? MenuPrimitive.GroupLabel : DropdownMenuStandaloneLabel return ( <Label data-slot="dropdown-menu-label" data-inset={inset ? "" : undefined} {...props} className={ inGroup ? mergeClassName(dropdownMenuLabelClassName, props.className) : props.className } /> ) } const dropdownMenuItemVariants = cva( "relative flex min-h-8 w-full min-w-0 cursor-default items-center gap-2 rounded-(--dropdown-menu-item-radius) px-2 py-1.5 text-start text-sm wrap-anywhere outline-none select-none focus-visible:outline-hidden data-highlighted:bg-accent data-highlighted:text-accent-foreground data-inset:ps-8 motion-safe:data-[chosen]:animate-menu-blink forced-colors:data-highlighted:outline-2 forced-colors:data-highlighted:-outline-offset-2 forced-colors:data-highlighted:outline-solid pointer-coarse:min-h-11 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-highlighted:[&_svg:not([class*='text-'])]:text-accent-foreground", { variants: { variant: { default: "", destructive: "text-destructive [--menu-blink:color-mix(in_oklab,var(--color-destructive)_10%,transparent)] data-highlighted:bg-destructive/10 data-highlighted:text-destructive dark:[--menu-blink:color-mix(in_oklab,var(--color-destructive)_20%,transparent)] dark:data-highlighted:bg-destructive/20 forced-colors:data-highlighted:outline-2 forced-colors:data-highlighted:-outline-offset-2 forced-colors:data-highlighted:outline-solid [&_svg:not([class*='text-'])]:text-destructive data-highlighted:[&_svg:not([class*='text-'])]:text-destructive", }, }, defaultVariants: { variant: "default", }, } ) type ChoosableEvent = React.MouseEvent<HTMLElement> & { baseUIHandlerPrevented?: boolean } function useChooseOnClick<E extends ChoosableEvent>( closeOnClick: boolean, onClick: ((event: E) => void) | undefined ) { const context = React.useContext(DropdownMenuContext) const id = React.useId() return { chosen: context?.chosen === id ? "" : undefined, onClick: (event: E) => { onClick?.(event) if ( context && closeOnClick && event.detail !== 0 && !event.defaultPrevented && !event.baseUIHandlerPrevented ) { context.choose(id) } }, } } type DropdownMenuItemProps = MenuPrimitive.Item.Props & VariantProps<typeof dropdownMenuItemVariants> & { inset?: boolean } function DropdownMenuItem({ className, variant = "default", inset, closeOnClick = true, onClick, ...props }: DropdownMenuItemProps) { const choice = useChooseOnClick(closeOnClick, onClick) return ( <MenuPrimitive.Item data-slot="dropdown-menu-item" data-variant={variant} data-inset={inset ? "" : undefined} data-chosen={choice.chosen} closeOnClick={closeOnClick} onClick={choice.onClick} className={mergeClassName( dropdownMenuItemVariants({ variant }), className )} {...props} /> ) } const dropdownMenuIndicatorClassName = "flex items-center justify-center opacity-0 transition-[opacity,scale] duration-150 ease-out-quint data-checked:opacity-100 motion-safe:scale-50 motion-safe:data-checked:scale-100 motion-reduce:transition-none" type DropdownMenuCheckboxItemProps = MenuPrimitive.CheckboxItem.Props & { inset?: boolean } function DropdownMenuCheckboxItem({ className, children, inset, closeOnClick = false, onClick, ...props }: DropdownMenuCheckboxItemProps) { const choice = useChooseOnClick(closeOnClick, onClick) return ( <MenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" data-inset={inset ? "" : undefined} data-chosen={choice.chosen} closeOnClick={closeOnClick} onClick={choice.onClick} className={mergeClassName( cn(dropdownMenuItemVariants(), "ps-8"), className )} {...props} > <span aria-hidden className="pointer-events-none absolute start-2 flex size-4 items-center justify-center" > <MenuPrimitive.CheckboxItemIndicator keepMounted data-slot="dropdown-menu-checkbox-item-indicator" className={dropdownMenuIndicatorClassName} > <IconCheck className="text-current" /> </MenuPrimitive.CheckboxItemIndicator> </span> {children} </MenuPrimitive.CheckboxItem> ) } function DropdownMenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) { return ( <DropdownMenuGroupContext.Provider value> <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} /> </DropdownMenuGroupContext.Provider> ) } type DropdownMenuRadioItemProps = MenuPrimitive.RadioItem.Props & { inset?: boolean } function DropdownMenuRadioItem({ className, children, inset, closeOnClick = false, onClick, ...props }: DropdownMenuRadioItemProps) { const choice = useChooseOnClick(closeOnClick, onClick) return ( <MenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" data-inset={inset ? "" : undefined} data-chosen={choice.chosen} closeOnClick={closeOnClick} onClick={choice.onClick} className={mergeClassName( cn(dropdownMenuItemVariants(), "ps-8"), className )} {...props} > <span aria-hidden className="pointer-events-none absolute start-2 flex size-4 items-center justify-center" > <MenuPrimitive.RadioItemIndicator keepMounted data-slot="dropdown-menu-radio-item-indicator" className={dropdownMenuIndicatorClassName} > <IconCircleFilled className="size-2 text-current" /> </MenuPrimitive.RadioItemIndicator> </span> {children} </MenuPrimitive.RadioItem> ) } function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { return ( <MenuPrimitive.Separator data-slot="dropdown-menu-separator" className={mergeClassName("-mx-1 my-1 h-px bg-border", className)} {...props} /> ) } function DropdownMenuShortcut({ className, dir = "ltr", children, ...props }: React.ComponentProps<"kbd">) { return ( <kbd data-slot="dropdown-menu-shortcut" className={cn( "ms-auto shrink-0 ps-3 font-sans text-xs tracking-widest text-muted-foreground in-data-highlighted:text-accent-foreground/70 in-data-[variant=destructive]:text-destructive/70", className )} {...props} > <span dir={dir} className="[unicode-bidi:isolate]"> {children} </span> </kbd> ) } function DropdownMenuSub(props: MenuPrimitive.SubmenuRoot.Props) { return <MenuPrimitive.SubmenuRoot {...props} /> } type DropdownMenuSubTriggerProps = MenuPrimitive.SubmenuTrigger.Props & { inset?: boolean } function DropdownMenuSubTrigger({ className, children, inset, ...props }: DropdownMenuSubTriggerProps) { return ( <MenuPrimitive.SubmenuTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset ? "" : undefined} className={mergeClassName( cn( dropdownMenuItemVariants(), "data-popup-open:bg-accent data-popup-open:text-accent-foreground forced-colors:data-popup-open:outline-2 forced-colors:data-popup-open:-outline-offset-2 forced-colors:data-popup-open:outline-solid" ), className )} {...props} > {children} <IconChevronRight aria-hidden data-slot="dropdown-menu-sub-trigger-icon" className="ms-auto rtl:-scale-x-100" /> </MenuPrimitive.SubmenuTrigger> ) } function DropdownMenuSubContent({ sideOffset = 0, alignOffset = -4, ...props }: DropdownMenuContentProps) { return ( <DropdownMenuPopup slot="dropdown-menu-sub-content" sideOffset={sideOffset} alignOffset={alignOffset} {...props} /> ) } const createDropdownMenuHandle = MenuPrimitive.createHandle export { DropdownMenu, DropdownMenuTrigger, DropdownMenuPortal, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, createDropdownMenuHandle, dropdownMenuItemVariants, } export type { DropdownMenuProps, DropdownMenuContentProps, DropdownMenuLabelProps, DropdownMenuItemProps, DropdownMenuCheckboxItemProps, DropdownMenuRadioItemProps, DropdownMenuSubTriggerProps, }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarShortcut,
MenubarTrigger,
} from "@/components/ui/menubar"<Menubar aria-label="Editor">
<MenubarMenu>
<MenubarTrigger>File</MenubarTrigger>
<MenubarContent>
<MenubarItem>
New tab <MenubarShortcut>⌘T</MenubarShortcut>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>Each <MenubarMenu /> is a Dropdown menu, so items, checkbox and radio items, submenus, labels and shortcuts work and look exactly the same. Open a menu, then move the pointer or press the arrow keys to sweep across the bar.
Menubar
└── MenubarMenu
├── MenubarTrigger
└── MenubarContent
├── MenubarGroup
│ ├── MenubarLabel
│ └── MenubarItem
│ └── MenubarShortcut
├── MenubarCheckboxItem
├── MenubarRadioGroup
│ └── MenubarRadioItem
├── MenubarSeparator
└── MenubarSub
├── MenubarSubTrigger
└── MenubarSubContentTrigger icons#
Put an icon before a trigger's label, or use an icon on its own with an aria-label. Icon-only triggers get a square shape.
import {
IconAdjustmentsHorizontal,
IconDots,
IconFolder,
IconLayoutGrid,
IconPencil,
} from "@tabler/icons-react"
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarTrigger,
} from "@/components/ui/menubar"
export function MenubarTriggerIcons() {
return (
<Menubar aria-label="Project">
<MenubarMenu>
<MenubarTrigger>
<IconFolder />
Project
</MenubarTrigger>
<MenubarContent>
<MenubarItem>Open…</MenubarItem>
<MenubarItem>Duplicate</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>
<IconPencil />
Edit
</MenubarTrigger>
<MenubarContent>
<MenubarItem>Rename</MenubarItem>
<MenubarItem>Move</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>
<IconLayoutGrid />
View
</MenubarTrigger>
<MenubarContent>
<MenubarItem>Grid</MenubarItem>
<MenubarItem>List</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger aria-label="More">
<IconDots />
</MenubarTrigger>
<MenubarContent align="end">
<MenubarItem>
<IconAdjustmentsHorizontal />
Preferences
</MenubarItem>
<MenubarSeparator />
<MenubarItem variant="destructive">Delete project</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
)
}Icons#
Put an icon before the label. Use variant="destructive" on items that delete or can't be undone.
import {
IconCopy,
IconDownload,
IconFilePlus,
IconFolderOpen,
IconScissors,
IconTrash,
} from "@tabler/icons-react"
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarShortcut,
MenubarTrigger,
} from "@/components/ui/menubar"
export function MenubarIcons() {
return (
<Menubar aria-label="Files">
<MenubarMenu>
<MenubarTrigger>File</MenubarTrigger>
<MenubarContent>
<MenubarItem>
<IconFilePlus />
New file
<MenubarShortcut>⌘N</MenubarShortcut>
</MenubarItem>
<MenubarItem>
<IconFolderOpen />
Open…
<MenubarShortcut>⌘O</MenubarShortcut>
</MenubarItem>
<MenubarItem>
<IconDownload />
Export
</MenubarItem>
<MenubarSeparator />
<MenubarItem variant="destructive">
<IconTrash />
Move to trash
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem>
<IconScissors />
Cut
<MenubarShortcut>⌘X</MenubarShortcut>
</MenubarItem>
<MenubarItem>
<IconCopy />
Copy
<MenubarShortcut>⌘C</MenubarShortcut>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
)
}Disabled#
Disable a single menu with disabled on <MenubarMenu />, or the whole bar on <Menubar />. Arrow keys skip disabled menus.
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarTrigger,
} from "@/components/ui/menubar"
export function MenubarDisabled() {
return (
<div className="flex flex-col items-center gap-4">
<Menubar aria-label="Document">
<MenubarMenu>
<MenubarTrigger>File</MenubarTrigger>
<MenubarContent>
<MenubarItem>Save</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu disabled>
<MenubarTrigger>Review</MenubarTrigger>
<MenubarContent>
<MenubarItem>Accept all changes</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>Help</MenubarTrigger>
<MenubarContent>
<MenubarItem>Keyboard shortcuts</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
<Menubar aria-label="Locked document" disabled>
<MenubarMenu>
<MenubarTrigger>File</MenubarTrigger>
<MenubarContent>
<MenubarItem>Save</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem>Undo</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
</div>
)
}Vertical#
orientation="vertical" stacks the menus and switches navigation to the up and down arrows. Open the menus to the side with side="inline-end".
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarTrigger,
} from "@/components/ui/menubar"
const menus = [
{ label: "Projects", items: ["All projects", "Starred", "Archived"] },
{ label: "Team", items: ["Members", "Roles", "Invitations"] },
{ label: "Billing", items: ["Plan", "Invoices", "Payment method"] },
]
export function MenubarVertical() {
return (
<Menubar aria-label="Workspace" orientation="vertical">
{menus.map((menu) => (
<MenubarMenu key={menu.label}>
<MenubarTrigger>{menu.label}</MenubarTrigger>
<MenubarContent side="inline-end" align="start" sideOffset={8}>
{menu.items.map((item) => (
<MenubarItem key={item}>{item}</MenubarItem>
))}
</MenubarContent>
</MenubarMenu>
))}
</Menubar>
)
}Right to left#
Menus, submenus and arrow keys follow the reading direction. Shortcuts stay in keyboard order.
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarShortcut,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from "@/components/ui/menubar"
export function MenubarRtl() {
return (
<div dir="rtl">
<Menubar aria-label="المحرر">
<MenubarMenu>
<MenubarTrigger>ملف</MenubarTrigger>
<MenubarContent>
<MenubarItem>
علامة تبويب جديدة
<MenubarShortcut>⌘T</MenubarShortcut>
</MenubarItem>
<MenubarSub>
<MenubarSubTrigger>مشاركة</MenubarSubTrigger>
<MenubarSubContent>
<MenubarItem>رابط البريد</MenubarItem>
</MenubarSubContent>
</MenubarSub>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger>تحرير</MenubarTrigger>
<MenubarContent>
<MenubarItem>تراجع</MenubarItem>
<MenubarItem>إعادة</MenubarItem>
</MenubarContent>
</MenubarMenu>
</Menubar>
</div>
)
}| Key | Action |
|---|---|
| Tab | Moves focus into the menubar, then out of it. |
| ←→ | Moves between menus. With a menu open, opens the next one instead. |
| EnterSpace↓ | Opens the focused menu. |
| ↑↓ | Moves between items in an open menu. |
| → | Opens a submenu from its trigger. |
| Esc | Closes the open menu and returns focus to its trigger. |
- Give the menubar a name with
aria-label. It rendersrole="menubar"and each trigger is a menu item, so screen readers announce it as an application menu. - The bar is a single tab stop. Arrow keys move between menus, so the page's tab order stays short.
- The sliding highlight is decoration. With reduced motion it just appears on the open menu.
Menu parts accept the same props as their Dropdown menu counterparts.
| Prop | Type | Default |
|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" |
loopFocusWrap from the last menu back to the first. | boolean | true |
modalBlock page scrolling and outside clicks while a menu is open. | boolean | true |
disabled | boolean | false |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="menubar" | Target the bar in CSS. |
data-orientation | The orientation. |
data-has-submenu-open | Present while any menu in the bar is open. |
data-slot="menubar-highlight" | The sliding background behind the open trigger, with data-visible. |
--menubar-radius | Corner radius of the bar. Triggers and the highlight derive theirs from it. |
| Prop | Type | Default |
|---|---|---|
open | boolean | – |
defaultOpen | boolean | false |
onOpenChange | (open: boolean, details) => void | – |
disabled | boolean | false |
| Attribute | Description |
|---|---|
data-slot="menubar-trigger" | Target triggers in CSS. |
data-popup-open | Present while its menu is open. |
data-disabled | Present when disabled. |
| Prop | Type | Default |
|---|---|---|
side | "top" | "bottom" | "left" | "right" | "inline-start" | "inline-end" | "bottom" |
align | "start" | "center" | "end" | "start" |
sideOffset | number | 8 |
alignOffsetLines the menu's items up with the trigger's label. | number | -4 |
| Attribute | Description |
|---|---|
data-slot="menubar-content" | Target menus in CSS. |
data-instant="group" | Present when the menu opened by sweeping from another one. It switches without animating. |
MenubarItem, MenubarCheckboxItem, MenubarRadioGroup, MenubarRadioItem, MenubarGroup, MenubarLabel, MenubarSeparator, MenubarShortcut, MenubarSub, MenubarSubTrigger and MenubarSubContent are the Dropdown menu parts with menubar-* slots. See the Dropdown menu API for their props.