Styled dropdown menu with keyboard navigation and typeahead, built on the @foldkit/ui Menu submodel.
Differences vs shadcn/ui
- No submenu, checkbox-item, or radio-item kinds — submenus render as labelled groups and checkbox/radio rows run off demo state; toggling one closes the panel.
Installation
Add this component to your project:
pnpm dlx shadcn@latest add @foldcn/menuSource
The component ships as plain source — no build step, no wrapper. Copy it and make it yours.
registry/default/ui/menu.ts155 lines/** Stateful submodel — import the whole module as a namespace and wire its
* Model/Message/init/update into your app:
* `import * as Menu from '@/components/ui/menu'`
*/
import { Menu as FoldkitMenu } from '@foldkit/ui'
import type { AnchorConfig } from '@foldkit/ui/menu'
import { childAttributes, inertHtml, type Html } from 'foldkit/html'
import { cn } from '@/lib/utils'
// Re-export the @foldkit/ui Menu surface. Create a bundle once per item type:
//
// export const ActionMenu = Menu.create<"Edit" | "Delete">()
//
// foldkit deltas: items highlight via data-active (upstream uses focus:) —
// prefix adjusted per docs/deriving-from-base.md; the panel's data-side is
// emitted here from the anchor placement (foldkit anchors expose placement,
// not side). Gaps vs upstream: no checkbox/radio/submenu/
// destructive/inset item kinds (primitive-level). Flat item path uses correct
// tokens/slots; per-item data-slot (dropdown-menu-item etc) cannot be stamped —
// primitive has no per-item attribute hook (documented gap). Sub-trigger uses
// data-popup-open per upstream but foldkit emits data-open — compat via style
// token's data-open handling (see cn-compat.css).
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
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
/** Upstream renders DropdownMenuTrigger unstyled (consumers pass a Button);
* foldcn's trigger builder keeps a ghost-button composition. */
export const menuTriggerClass = 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=\'size-\'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2'
export const menuItemsClass =
'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 text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 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 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 menuItemsAnimatedClass = menuItemsClass
export const menuItemClass =
'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/dropdown-menu-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 menuSeparatorClass = 'bg-border -mx-1 my-1 h-px'
export const menuHeadingClass = 'text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7'
export const menuShortcutClass = 'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest group-data-active/dropdown-menu-item:text-accent-foreground'
export const menuLabelClass = menuHeadingClass
export const menuSubTriggerClass =
'focus:bg-accent focus:text-accent-foreground 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 data-open:bg-accent data-open:text-accent-foreground data-[popup-open]:bg-accent data-[popup-open]:text-accent-foreground flex cursor-default items-center outline-hidden select-none data-popup-open:bg-accent data-popup-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0'
export const menuCheckboxItemClass =
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm 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 data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
export const menuRadioItemClass =
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm 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 data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
export const menuBackdropClass = 'fixed inset-0 z-0'
export const menuWrapperClass = 'relative inline-block'
export const MENU_ANCHOR: AnchorConfig = {
placement: 'bottom-start',
gap: 4,
padding: 8,
}
export type MenuViewInputsConfig<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` with foldcn's classes baked in. */
export const viewInputs = <Item extends string>(
config: MenuViewInputsConfig<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 ?? MENU_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(menuHeadingClass, heading.className),
}
}
: undefined,
ariaLabel: config.ariaLabel,
ariaLabelledBy: config.ariaLabelledBy,
buttonClassName: cn(menuTriggerClass, config.triggerClass),
buttonAttributes: childAttributes([inertHtml.DataAttribute('slot', 'dropdown-menu-trigger')]),
itemsClassName: cn(
config.isAnimated !== false ? menuItemsAnimatedClass : menuItemsClass,
config.itemsClass,
),
itemsAttributes: childAttributes([
inertHtml.DataAttribute('slot', 'dropdown-menu-content'),
inertHtml.DataAttribute('side', side),
]),
itemToConfig: (item, context) => {
const { className, content } = config.itemToConfig(item, context)
return { className: cn(menuItemClass, config.itemClass, className), content }
},
separatorClassName: cn(menuSeparatorClass, config.separatorClass),
separatorAttributes: childAttributes([
inertHtml.DataAttribute('slot', 'dropdown-menu-separator'),
]),
groupClassName: config.groupClass,
groupAttributes: childAttributes([inertHtml.DataAttribute('slot', 'dropdown-menu-group')]),
backdropClassName: cn(menuBackdropClass, config.backdropClass),
className: cn(menuWrapperClass, config.wrapperClass),
attributes: childAttributes([inertHtml.DataAttribute('slot', 'dropdown-menu')]),
}
}