All components
Menu
Dropdown command lists with items, group labels, keyboard shortcut hints, and separators.
123456789101112131415161718192021222324252627282930import { Menu } from "#components/ui/base-ui/menu.tsx";import { Button } from "#components/ui/base-ui/button.tsx";
export function Example() { return ( // size/variant resolve once on Root; parts inherit them via context. // Portal → Positioner → Popup is Base UI's real overlay chain; keyboard // highlight styles key off the engine's data-highlighted attribute. <Menu.Root size="md"> <Menu.Trigger render={<Button variant="surface" />}>Options</Menu.Trigger> <Menu.Portal> <Menu.Positioner sideOffset={8}> <Menu.Popup> <Menu.Group> <Menu.GroupLabel>Actions</Menu.GroupLabel> <Menu.Item> <Menu.ItemText>New file</Menu.ItemText> <Menu.ItemCommand>⌘N</Menu.ItemCommand> </Menu.Item> </Menu.Group> <Menu.Separator /> <Menu.Item> <Menu.ItemText>Delete</Menu.ItemText> </Menu.Item> </Menu.Popup> </Menu.Positioner> </Menu.Portal> </Menu.Root> );}Variants
subtle
solid
Sizes
sm
md
Install
1npx stylexblocks add menuSource
components/ui/base-ui/menu.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293import * as stylex from "@stylexjs/stylex";import { Menu as BaseMenu } from "@base-ui-components/react/menu";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, resolveSlotStyles, type StyleOverrideProps,} from "#lib/recipe-props.ts";import { semanticShadows } from "@stylexblocks/ui/semantic-tokens/shadows.stylex.ts";import { cursor } from '@stylexblocks/ui/tokens/cursor.stylex.ts'import { durations } from '@stylexblocks/ui/tokens/durations.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { letterSpacings } from '@stylexblocks/ui/tokens/letter-spacings.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'import { zIndices } from '@stylexblocks/ui/tokens/z-indices.stylex.ts'
const menuContent = stylex.create({ base: { outline: 'none', color: semanticColors.fg, maxHeight: 'var(--available-height)', '--menu-z-index': zIndices.layer3, zIndex: 'calc(var(--menu-z-index) + var(--layer-index, 0))', overflowY: 'auto', scrollbarWidth: 'thin', transformOrigin: 'var(--transform-origin)', animationName: { default: null, ':is([data-open], [data-state="open"])': 'fade-in, slide-from-top', ':is([data-closed], [data-state="closed"])': 'fade-out', }, animationDuration: { default: null, ':is([data-open], [data-state="open"])': durations.fast, ':is([data-closed], [data-state="closed"])': durations.faster, }, },})
const menuContentSizes = stylex.create({ sm: { minWidth: '8rem', padding: spacing._1, borderRadius: semanticRadii.panel, }, md: { minWidth: '8rem', padding: spacing._1, },})
const menuItem = stylex.create({ base: { textDecoration: 'none', color: semanticColors.fg, userSelect: 'none', borderRadius: semanticRadii.control, width: '100%', display: 'flex', cursor: { default: cursor.menuitem, ':is(:disabled, [disabled], [data-disabled])': 'not-allowed', }, alignItems: 'center', textAlign: 'start', position: 'relative', flex: '0 0 auto', outline: 'none', opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, },})
const menuItemSizes = stylex.create({ sm: { gap: spacing._1, fontSize: fontSizes.xs, lineHeight: lineHeights.xs, minHeight: sizes._6, paddingInline: spacing._1_5, paddingInlineStart: `var(--menu-item-inset, ${spacing._1_5})`, }, md: { gap: spacing._2, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, minHeight: sizes._7, paddingInline: spacing._2, paddingInlineStart: `var(--menu-item-inset, ${spacing._2})`, },})
const menuItemVariants = stylex.create({ subtle: {}, solid: {},})
const menuItemText = stylex.create({ base: { flex: 1, },})
const menuItemGroupLabel = stylex.create({ base: { display: 'flex', alignItems: 'center', fontSize: fontSizes.sm, lineHeight: lineHeights.sm, color: semanticColors.fgSubtle, },})
const menuItemGroupLabelSizes = stylex.create({ sm: { fontSize: fontSizes.xs, lineHeight: lineHeights.xs, minHeight: sizes._6, paddingInline: spacing._1_5, }, md: { fontSize: fontSizes.sm, lineHeight: lineHeights.sm, minHeight: sizes._7, paddingInline: spacing._2, },})
const menuIndicator = stylex.create({ base: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, },})
const menuItemCommand = stylex.create({ base: { opacity: 0.6, fontSize: fontSizes.xs, lineHeight: lineHeights.xs, marginInlineStart: 'auto', paddingInlineStart: spacing._4, letterSpacing: letterSpacings.widest, },})
const menuSeparator = stylex.create({ base: { height: '1px', backgroundColor: semanticColors.bgMuted, marginBlock: spacing._1, marginInline: spacing._1, },})
export type MenuVariant = keyof typeof menuItemVariants
export type MenuSize = keyof typeof menuContentSizes
// defineSlotRecipe validates the shape at compile time: default values must// exist in the axis maps, and slot keys must be `styles` or a declared axisconst menuRecipe = defineSlotRecipe({ slots: { content: { styles: menuContent, sizes: menuContentSizes }, item: { styles: menuItem, sizes: menuItemSizes, variants: menuItemVariants }, itemText: { styles: menuItemText }, itemGroupLabel: { styles: menuItemGroupLabel, sizes: menuItemGroupLabelSizes, }, indicator: { styles: menuIndicator }, itemCommand: { styles: menuItemCommand }, separator: { styles: menuSeparator }, }, defaultVariants: { size: "md", variant: "subtle" },});
// Local chrome the recipe leaves to the composition layer: the dropdown// surface, keyboard/pointer highlight, and logical-property fixes for// generated Chakra shorthands (`ms`/`ps`).const chrome = stylex.create({ content: { "--menu-z-index": zIndices.dropdown, backgroundColor: semanticColors.bgPanel, borderRadius: semanticRadii.panel, boxShadow: semanticShadows.overlay, }, item: { paddingBlock: spacing._1, backgroundColor: { default: null, ":is([data-highlighted])": semanticColors.bgSubtle, }, }, itemCommand: { marginInlineStart: "auto", paddingInlineStart: spacing._4, }, indicator: { width: "1rem", height: "1rem", display: "inline-flex", visibility: { default: "visible", ":is([hidden], [data-unchecked])": "hidden", }, },});
function CheckIcon(props: React.ComponentProps<"svg">) { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}> <path d="m3.5 8.5 3 3 6-7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> );}
const { withRootProvider, withContext } = createSlotRecipeContext(menuRecipe);
const StyledRadioItemIndicator = withContext( BaseMenu.RadioItemIndicator, (v) => [...resolveSlotStyles(menuRecipe.slots.indicator, v), chrome.indicator], "MenuRadioItemIndicator",);
function RadioItemIndicator( allProps: React.ComponentProps<typeof BaseMenu.RadioItemIndicator> & StyleOverrideProps,) { const { children, keepMounted = true, ...rest } = allProps; return ( <StyledRadioItemIndicator keepMounted={keepMounted} {...rest}> {children ?? <CheckIcon />} </StyledRadioItemIndicator> );}RadioItemIndicator.displayName = "MenuRadioItemIndicator";
/** * Menu — Base UI's menu machine wearing the menu slot recipe: real portal * (via `Menu.Portal`), anchor positioning, typeahead, and keyboard * navigation all come from the engine's Portal → Positioner → Popup chain * (highlight styles key off the engine's `data-highlighted`). * `<Menu.Root size variant>` resolves the recipe once; every part inherits * it through context and composes via `render`. Portal and Positioner stay * unstyled engine parts. */export const Menu = { Root: withRootProvider(BaseMenu.Root, "MenuRoot"), Trigger: BaseMenu.Trigger, Portal: BaseMenu.Portal, Positioner: BaseMenu.Positioner, Popup: withContext( BaseMenu.Popup, (v) => [...resolveSlotStyles(menuRecipe.slots.content, v), chrome.content], "MenuPopup", ), Item: withContext( BaseMenu.Item, (v) => [...resolveSlotStyles(menuRecipe.slots.item, v), chrome.item], "MenuItem", ), SubmenuRoot: BaseMenu.SubmenuRoot, SubmenuTrigger: withContext( BaseMenu.SubmenuTrigger, (v) => [...resolveSlotStyles(menuRecipe.slots.item, v), chrome.item], "MenuSubmenuTrigger", ), ItemText: withContext("span", "itemText", "MenuItemText"), Group: BaseMenu.Group, GroupLabel: withContext(BaseMenu.GroupLabel, "itemGroupLabel", "MenuGroupLabel"), RadioGroup: BaseMenu.RadioGroup, RadioItem: withContext( BaseMenu.RadioItem, (v) => [...resolveSlotStyles(menuRecipe.slots.item, v), chrome.item], "MenuRadioItem", ), RadioItemIndicator, ItemCommand: withContext( "span", (v) => [...resolveSlotStyles(menuRecipe.slots.itemCommand, v), chrome.itemCommand], "MenuItemCommand", ), Separator: withContext(BaseMenu.Separator, "separator", "MenuSeparator"),};
Examples
Installable blocks built with Menu — real use cases, delivered as source you own.
Model Picker
Settings menu for choosing an inference model — Fast switch, Effort submenu, and a searchable combobox for the model list.