Menubar
Horizontal bar of menus built on the @foldkit/ui Menu submodel. ⚠ Each trigger is an independent menu — no cross-menu arrow-key traversal yet.
Differences vs shadcn/ui
- Renders independent menus in a bar — no cross-menu keyboard traversal or open-on-hover of the next trigger.
Installation
Add this component to your project:
pnpm dlx shadcn@latest add @foldcn/menubarSource
The component ships as plain source — no build step, no wrapper. Copy it and make it yours.
registry/default/ui/menubar.ts191 lines/** ⚠ BEHAVIOR GAP vs upstream shadcn: each trigger is an independent menu bundle — no ArrowLeft/Right traversal between menus and no open-on-hover-of-next-trigger.
* The styled surface matches, but this behavior is absent — do not use
* where that behavior is required.
*/
/** Stateful submodel — import the whole module as a namespace and wire its
* Model/Message/init/update into your app:
* `import * as Menubar from '@/components/ui/menubar'`
*/
import { Menu as FoldkitMenu } from '@foldkit/ui'
import type { AnchorConfig } from '@foldkit/ui/menu'
import { childAttributes, inertHtml, type Html, type HtmlBuilder } from 'foldkit/html'
import { cn } from '@/lib/utils'
// Re-export the @foldkit/ui Menu surface. A menubar is a horizontal bar of
// menu triggers; each trigger is a Menu instance. This module provides the
// bar/trigger styling and a `viewInputs` helper for the per-trigger menus.
export const create = FoldkitMenu.create
export const init = (config: InitConfig): Model => FoldkitMenu.init({ isAnimated: true, ...config })
export const buttonId = FoldkitMenu.buttonId
export const Model = FoldkitMenu.Model
export type Model = typeof Model.Type
export const Message = FoldkitMenu.Message
export type Message = typeof Message.Type
export const OutMessage = FoldkitMenu.OutMessage
export type OutMessage = typeof OutMessage.Type
// foldcn gaps vs upstream (primitive ceiling — needs @foldkit/ui work): each
// trigger is an independent Menu bundle — no cross-menu ArrowLeft/Right
// traversal or open-on-hover-of-next-trigger (upstream Menubar primitive
// behavior). No submenu/checkbox/radio item kinds (upstream MenubarSub,
// MenubarCheckboxItem, MenubarRadioGroup/Item) — the Menu primitive has flat
// items + labeled groups only; demo state can toggle check/radio affordances
// (see the web menubar demo). No per-item data-inset/data-variant attributes
// (upstream MenubarItem inset/destructive props) and no per-item data-slot
// (menubar-item etc) — the primitive has no per-item attribute hook.
// Menubar content/item/label/separator/shortcut use the cn-menubar-* family.
// Group headings render via itemGroupKey/groupToHeading (upstream
// MenubarGroup/MenubarLabel); separators render between groups. The panel is
// portaled to the document body by the primitive itself (upstream
// MenubarPortal equivalent). Align offset (-4) has no anchor equivalent.
// Items highlight via data-active (upstream focus:) per the derivation mapping;
// the panel's data-side is emitted from the anchor placement.
export type Bundle<Item extends string = string> = FoldkitMenu.Bundle<Item>
export type InitConfig = FoldkitMenu.InitConfig
export type ViewInputs<Item extends string = string> = FoldkitMenu.ViewInputs<Item>
export type ItemConfig = FoldkitMenu.ItemConfig
export type GroupHeading = FoldkitMenu.GroupHeading
export const menubarClass = 'h-8 gap-0.5 rounded-lg border p-[3px] flex items-center'
/** Upstream menubar trigger token string. */
export const menubarTriggerClass = 'hover:bg-muted aria-expanded:bg-muted rounded-sm px-1.5 py-[2px] text-sm font-medium flex items-center outline-hidden select-none'
/** Upstream MenubarContent renders DropdownMenuContent (which owns the panel's
* scroll-container utilities) with the menubar tokens layered on. Mirror that
* composition so the panel scrolls intentionally like upstream instead of
* relying on the anchor's inline overflow (whose computed overflow-x: auto
* risks horizontal scrollbars). */
export const menubarContentClass =
'data-enter:animate-in data-leave:animate-out data-leave:fade-out-0 data-enter:fade-in-0 data-leave:zoom-out-95 data-enter:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 not-data-[side=bottom]:data-[placement=bottom]:slide-in-from-top-2 not-data-[side=left]:data-[placement=left]:slide-in-from-right-2 not-data-[side=right]:data-[placement=right]:slide-in-from-left-2 not-data-[side=top]:data-[placement=top]:slide-in-from-bottom-2 [--anchor-width:var(--button-width)] data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 animate-none! relative before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! bg-popover text-popover-foreground data-enter:animate-in data-enter:fade-in-0 data-enter:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden'
export const menubarContentAnimatedClass = menubarContentClass
export const menubarItemClass =
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=\'size-\'])]:size-4 group/menubar-item relative flex cursor-default items-center outline-hidden select-none data-active:bg-accent data-active:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
export const menubarSeparatorClass = 'bg-border -mx-1 my-1 h-px'
export const menubarHeadingClass = 'px-1.5 py-1 text-sm font-medium data-inset:pl-7'
/** Upstream `MenubarLabel` token (alias — foldcn historically named it heading). */
export const menubarLabelClass = menubarHeadingClass
export const menubarShortcutClass = 'text-muted-foreground group-focus/menubar-item:text-accent-foreground text-xs tracking-widest group-data-active/menubar-item:text-accent-foreground ml-auto'
/** Upstream `MenubarCheckboxItem` token string (verbatim). No foldkit highlight
* twin: upstream keys checkbox highlight on focus: inside the token. */
export const menubarCheckboxItemClass =
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm data-inset:pl-7 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
export const menubarCheckboxItemIndicatorClass =
'left-1.5 size-4 [&_svg:not([class*=\'size-\'])]:size-4 pointer-events-none absolute flex items-center justify-center'
/** Upstream `MenubarRadioItem` token string (verbatim — note: no disabled
* opacity, matching upstream). */
export const menubarRadioItemClass =
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm data-disabled:opacity-50 data-inset:pl-7 [&_svg:not([class*=\'size-\'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0'
export const menubarRadioItemIndicatorClass =
'left-1.5 size-4 [&_svg:not([class*=\'size-\'])]:size-4 pointer-events-none absolute flex items-center justify-center'
/** Upstream `MenubarSubTrigger` token (verbatim). Open-state styling arrives
* via cn-compat.css twins (foldkit emits data-open, Base UI data-popup-open). */
export const menubarSubTriggerClass = 'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=\'size-\'])]:size-4 data-open:bg-accent data-open:text-accent-foreground data-[popup-open]:bg-accent data-[popup-open]:text-accent-foreground'
/** Upstream `MenubarSubContent` token string (verbatim). */
export const menubarSubContentClass = 'text-popover-foreground data-enter:animate-in data-leave:animate-out data-leave:fade-out-0 data-enter:fade-in-0 data-leave:zoom-out-95 data-enter:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-32 rounded-lg p-1 shadow-lg ring-1 duration-100 animate-none! relative before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! bg-popover'
export const menubarBackdropClass = 'fixed inset-0 z-0'
export const menubarWrapperClass = 'relative inline-block'
// Upstream MenubarContent: align=start (placement bottom-start), sideOffset=8
// (gap), alignOffset=-4 (no anchor equivalent — documented gap above).
export const MENUBAR_ANCHOR: AnchorConfig = {
placement: 'bottom-start',
gap: 8,
padding: 8,
}
export type MenubarViewInputsConfig<Item extends string> = Readonly<{
items: ReadonlyArray<Item>
itemToConfig: ViewInputs<Item>['itemToConfig']
buttonContent: Html
anchor?: AnchorConfig
isItemDisabled?: (item: Item, index: number) => boolean
itemToSearchText?: (item: Item, index: number) => string
isButtonDisabled?: boolean
isAnimated?: boolean
itemGroupKey?: (item: Item, index: number) => string
groupToHeading?: (groupKey: string) => GroupHeading | undefined
triggerClass?: string
itemsClass?: string
itemClass?: string
backdropClass?: string
wrapperClass?: string
separatorClass?: string
groupClass?: string
ariaLabel?: string
ariaLabelledBy?: string
}>
/** Build styled `Menu.ViewInputs` for a menubar trigger's dropdown. */
export const viewInputs = <Item extends string>(
config: MenubarViewInputsConfig<Item>,
): ViewInputs<Item> => {
// Upstream slide-in variants key on data-side; foldkit anchors expose
// placement ("bottom-start"), so derive the physical side here.
const anchor = config.anchor ?? MENUBAR_ANCHOR
const side = (anchor.placement ?? 'bottom').split('-')[0] || 'bottom'
return {
items: config.items,
anchor,
isItemDisabled: config.isItemDisabled,
itemToSearchText: config.itemToSearchText,
isButtonDisabled: config.isButtonDisabled,
buttonContent: config.buttonContent,
itemGroupKey: config.itemGroupKey,
groupToHeading: config.groupToHeading
? (groupKey) => {
const heading = config.groupToHeading!(groupKey)
if (!heading) return undefined
return {
content: heading.content,
className: cn(menubarHeadingClass, heading.className),
}
}
: undefined,
ariaLabel: config.ariaLabel,
ariaLabelledBy: config.ariaLabelledBy,
buttonClassName: cn(menubarTriggerClass, config.triggerClass),
buttonAttributes: childAttributes([inertHtml.DataAttribute('slot', 'menubar-trigger')]),
itemsClassName: cn(
config.isAnimated !== false ? menubarContentAnimatedClass : menubarContentClass,
config.itemsClass,
),
itemsAttributes: childAttributes([
inertHtml.DataAttribute('slot', 'menubar-content'),
inertHtml.DataAttribute('side', side),
]),
itemToConfig: (item, context) => {
const { className, content } = config.itemToConfig(item, context)
return { className: cn(menubarItemClass, config.itemClass, className), content }
},
separatorClassName: cn(menubarSeparatorClass, config.separatorClass),
separatorAttributes: childAttributes([inertHtml.DataAttribute('slot', 'menubar-separator')]),
groupClassName: config.groupClass,
groupAttributes: childAttributes([inertHtml.DataAttribute('slot', 'menubar-group')]),
backdropClassName: cn(menubarBackdropClass, config.backdropClass),
className: cn(menubarWrapperClass, config.wrapperClass),
attributes: childAttributes([inertHtml.DataAttribute('slot', 'menubar')]),
}
}
/** Wrap a single menubar trigger's menu in the bar container. */
export const menubar = <M>(children: ReadonlyArray<Html>, h: HtmlBuilder<M>): Html =>
h.div([h.Class(cn(menubarClass)), h.DataAttribute('slot', 'menubar')], children)