Styled dropdown menu with keyboard navigation and typeahead, built on the @foldkit/ui Menu submodel.
Add this component to your project:
npx shadcn@latest add @foldcn/menuThe component ships as plain source โ no build step, no wrapper. Copy it and make it yours.
registry/default/ui/menu.ts97 linesimport { Menu as FoldkitMenu } from '@foldkit/ui'
import type { AnchorConfig } from '@foldkit/ui/menu'
import 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">()
export const create = FoldkitMenu.create
export const init = FoldkitMenu.init
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
export const menuTriggerClass =
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
export const menuItemsClass =
'z-50 min-w-48 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95'
export const menuItemsAnimatedClass =
'z-50 min-w-48 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95'
export const menuItemClass =
"relative flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none transition-colors focus:bg-accent focus:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
export const menuSeparatorClass = '-mx-1 my-1 h-px bg-border'
export const menuHeadingClass = 'px-2 py-1.5 text-xs font-semibold text-muted-foreground'
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
}>
/** Build styled `Menu.ViewInputs` with foldcn's classes baked in. */
export const viewInputs = <Item extends string>(
config: MenuViewInputsConfig<Item>,
): ViewInputs<Item> => ({
items: config.items,
anchor: config.anchor ?? MENU_ANCHOR,
isItemDisabled: config.isItemDisabled,
itemToSearchText: config.itemToSearchText,
isButtonDisabled: config.isButtonDisabled,
buttonContent: config.buttonContent,
itemGroupKey: config.itemGroupKey,
groupToHeading: config.groupToHeading,
buttonClassName: cn(menuTriggerClass, config.triggerClass),
itemsClassName: cn(
config.isAnimated === true ? menuItemsAnimatedClass : menuItemsClass,
config.itemsClass,
),
itemToConfig: (item, context) => {
const { className, content } = config.itemToConfig(item, context)
return { className: cn(menuItemClass, config.itemClass, className), content }
},
separatorClassName: menuSeparatorClass,
groupClassName: menuHeadingClass,
backdropClassName: cn(menuBackdropClass, config.backdropClass),
className: cn(menuWrapperClass, config.wrapperClass),
})