Menu
Menu is the dropdown: a trigger, a floating popup, and a column of items. It does the two jobs a dropdown has in azucar. It picks one value from a short list, like a facility or a time range. And it holds a few actions that do not earn their own buttons, like copy link or export CSV. Base ui's menu sits underneath, so focus, keyboard navigation, and dismissal come from there.
import Menu, { MenuTrigger, MenuItem, MenuLabel, MenuSeparator } from "@haloid/azucar/ui/menu";
Two spellings name the same components. Menu.Item and the other dot-notation parts work only in Client Components: the dot-notation properties do not survive the server to client reference proxy. The named parts work everywhere. When in doubt, use the named parts.
Anatomy
The root takes the trigger through a prop and the popup content as children. Menu.Label names a run of items. Menu.Separator draws a hairline between the pick list and the actions. Open the menu below and move the pointer through it.
<Menu trigger={<Menu.Trigger label="TOR5" />}> <Menu.Label>Facility</Menu.Label> <Menu.Item onClick={() => {}}>TOR5</Menu.Item> <Menu.Item onClick={() => {}}>ATL2</Menu.Item> <Menu.Item onClick={() => {}}>DFW1</Menu.Item> <Menu.Separator /> <Menu.Item onClick={() => {}}>Copy link</Menu.Item> <Menu.Item onClick={() => {}}>Export CSV</Menu.Item> </Menu>
Menu.Trigger is the house pill: a soft shadow, a string label, and an optional icon node after it. The facility picker passes a small chevron. The popup is a fixed 240px surface with the standard bg-bg-1 shell and shadow-md.
The trigger is a prop
The root takes its trigger through an explicit trigger slot, never as a child.
<Menu trigger={<Menu.Trigger label="Last 30 days" />}>{/* items */}</Menu>
The first version found the trigger child by element type. That broke: element-type identity does not survive the server to client boundary, so a trigger composed from a Server Component silently disappeared. A named slot cannot disappear, so the slot stayed.
The slot also accepts more than Menu.Trigger. Any Base UI trigger works. CardMenu in Card passes a raw BaseMenu.Trigger that renders through the card header chip, so menu triggers align with tabs and actions by construction.
Menu as a select
Wire the trigger label to state and the menu becomes a small select. CardMenu is this pattern packed into one component. The control panels on this docs site use it too.
function RangePicker() { const [range, setRange] = useState("Last 30 days"); const ranges = ["Last 7 days", "Last 30 days", "Last quarter"]; return ( <Menu trigger={<Menu.Trigger label={range} />}> <Menu.Label>Time range</Menu.Label> {ranges.map((r) => ( <Menu.Item key={r} onClick={() => setRange(r)}> {r} </Menu.Item> ))} </Menu> ); } render(<RangePicker />);
Base ui closes the popup after an item click, so onClick is all a selection needs.
Interaction
One hover pill serves the whole popup. It is a single layoutId surface, so it springs from item to item instead of blinking off and on. When the pointer leaves an item, the root waits 350ms before it clears the pill. That grace period lets the pill glide across a label or a separator. Without it, every crossing would fade the pill out and back in. When the pointer leaves the popup the pill clears at once, and so does closing the menu.
The popup is a keepMounted portal. Closed menus stay in the DOM at opacity zero. This is deliberate: an unmounted popup remounts already at its target pose, so a Motion initial animation never plays. To keep it mounted and drive animate from the open state is the Base UI pattern for Motion enter animations. The enter is a 150ms fade with a small scale and drop, fast enough to read as instant.
Escape, outside click, arrow keys, and typeahead all work. Base ui provides them. Not supported: a styled keyboard highlight. The pill follows the mouse only, so arrow-key users move an invisible cursor.
In the product
The richest composition is the facility picker, in packages/azucar/src/facility-picker/index.tsx. It shows the important property of the popup: it is a surface, not a list. The picker puts a header row with an expanding search input inside it, filters the facility items through FilterList, and keeps the aggregate options below a separator.
<MenuonOpenChange={handleOpenChange} // resets the search when the menu closestrigger={<MenuTrigger label={label} icon={<SFChevronUpChevronDown size={12} />} />}><div className="flex flex-row-reverse justify-between mb-1"><ButtonWithInput /* expanding search */ /><MenuLabel>Facility</MenuLabel> {/* hides while the search is open */}</div><FilterList query={searchInput} anchor={value}>{facilities.map((facility) => (<FilterList.Item key={facility.code} searchText={facility.code}><MenuItem onClick={() => onChange(facility.code)}>{facility.code}</MenuItem></FilterList.Item>))}</FilterList><MenuSeparator /><MenuItem onClick={() => onChange("all")}>All facilities</MenuItem></Menu>
One layout note from that file: the hover pill overhangs each item by 6px per side. A scroll container inside the popup will clip it. The picker gives it clearance with negative margins and matching padding, full-bleed to the popup's own p-3.
API
// Menu (root)interface MenuProps {trigger: React.ReactNode; // explicit slot; see "the trigger is a prop"children: React.ReactNode;onOpenChange?: (open: boolean) => void; // fires after the pill clears on close}// Menu.Trigger: { label: string; icon?: React.ReactNode; className? }// Menu.Item: { children; onClick?: () => void; className? }// Menu.Label: { children?; className? }// Menu.Separator: { className? }
Not supported: controlled open state, disabled items, submenus, checkbox and radio items, and grouping. Menu.Label is a plain div, not Base UI's GroupLabel, because the aria wiring of GroupLabel requires a surrounding Group and grouping is not part of this surface yet. Positioning is fixed too: the popup always opens below the trigger, aligned to its start edge, 8px away, 240px wide.