All components
Menu
Dropdown command lists with items, group labels, keyboard shortcut hints, and separators.
1234567891011121314151617181920212223242526272829303132import { Menu } from "#components/ui/ark-ui/menu.tsx";import { Button } from "#components/ui/ark-ui/button.tsx";
export function Example() { return ( // size/variant resolve once on Root; parts inherit them via context. // The positioner portals to <body>; keyboard highlight styles key off // the machine's data-highlighted attribute. <Menu.Root size="md"> <Menu.Trigger asChild> <Button variant="surface">Options</Button> </Menu.Trigger> <Menu.Portal> <Menu.Positioner> <Menu.Content> <Menu.ItemGroup> <Menu.ItemGroupLabel>Actions</Menu.ItemGroupLabel> <Menu.Item value="new-file"> <Menu.ItemText>New file</Menu.ItemText> <Menu.ItemCommand>⌘N</Menu.ItemCommand> </Menu.Item> </Menu.ItemGroup> <Menu.Separator /> <Menu.Item value="delete"> <Menu.ItemText>Delete</Menu.ItemText> </Menu.Item> </Menu.Content> </Menu.Positioner> </Menu.Portal> </Menu.Root> );}Variants
subtle
solid
Sizes
sm
md
Install
1npx stylexblocks add menuSource
components/ui/ark-ui/menu.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291import * as stylex from "@stylexjs/stylex";import { Menu as ArkMenu } from "@ark-ui/react/menu";import { Portal } from "@ark-ui/react/portal";import { createSlotRecipeContext } from "#lib/ark-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])": "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 StyledItemIndicator = withContext( ArkMenu.ItemIndicator, (v) => [...resolveSlotStyles(menuRecipe.slots.indicator, v), chrome.indicator], "MenuItemIndicator",);
function ItemIndicator( allProps: React.ComponentProps<typeof ArkMenu.ItemIndicator> & StyleOverrideProps,) { const { children, ...rest } = allProps; return ( <StyledItemIndicator {...rest}>{children ?? <CheckIcon />}</StyledItemIndicator> );}ItemIndicator.displayName = "MenuItemIndicator";
/** * Menu — Ark's menu machine wearing the menu slot recipe: real portal to * `document.body`, anchor positioning, typeahead, and keyboard navigation * all come from the machine (highlight styles key off the machine's * `data-highlighted`). `<Menu.Root size variant>` resolves the recipe once; * every part inherits it through context and composes via `asChild`. Portal * and Positioner stay unstyled engine parts. */export const Menu = { Root: withRootProvider(ArkMenu.Root, "MenuRoot"), Trigger: ArkMenu.Trigger, Context: ArkMenu.Context, Portal, Positioner: ArkMenu.Positioner, Content: withContext( ArkMenu.Content, (v) => [...resolveSlotStyles(menuRecipe.slots.content, v), chrome.content], "MenuContent", ), Item: withContext( ArkMenu.Item, (v) => [...resolveSlotStyles(menuRecipe.slots.item, v), chrome.item], "MenuItem", ), TriggerItem: withContext( ArkMenu.TriggerItem, (v) => [...resolveSlotStyles(menuRecipe.slots.item, v), chrome.item], "MenuTriggerItem", ), ItemText: withContext(ArkMenu.ItemText, "itemText", "MenuItemText"), ItemGroup: ArkMenu.ItemGroup, ItemGroupLabel: withContext(ArkMenu.ItemGroupLabel, "itemGroupLabel", "MenuItemGroupLabel"), RadioItemGroup: ArkMenu.RadioItemGroup, RadioItem: withContext( ArkMenu.RadioItem, (v) => [...resolveSlotStyles(menuRecipe.slots.item, v), chrome.item], "MenuRadioItem", ), ItemIndicator, ItemCommand: withContext( "span", (v) => [...resolveSlotStyles(menuRecipe.slots.itemCommand, v), chrome.itemCommand], "MenuItemCommand", ), Separator: withContext(ArkMenu.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.