Components

Dropdown Menu

Displays a menu of actions triggered by a buttonView source →

Displays a menu of actions or options triggered by a button. Use dropdown menus for contextual actions, navigation options, and selection interfaces where space is limited.

This component is built on Radix UI Dropdown Menu. For the full primitive API reference, see the Radix documentation.

Installation

shell
npm install @kushagradhawan/kookie-ui

Usage

tsx
import { DropdownMenu, Button } from '@kushagradhawan/kookie-ui';
 
export function MyComponent() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        <Button variant="soft">Options</Button>
      </DropdownMenu.Trigger>
      <DropdownMenu.Content>
        <DropdownMenu.Item>Edit</DropdownMenu.Item>
        <DropdownMenu.Item>Duplicate</DropdownMenu.Item>
        <DropdownMenu.Separator />
        <DropdownMenu.Item color="red">Delete</DropdownMenu.Item>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  );
}

Anatomy

The dropdown menu is composed of several parts:

tsx
<DropdownMenu.Root>
  <DropdownMenu.Trigger>
    <Button>Trigger</Button>
  </DropdownMenu.Trigger>
  <DropdownMenu.Content>
    <DropdownMenu.Label>Label</DropdownMenu.Label>
    <DropdownMenu.Item>Item</DropdownMenu.Item>
    <DropdownMenu.Group>
      <DropdownMenu.Item>Grouped Item</DropdownMenu.Item>
    </DropdownMenu.Group>
    <DropdownMenu.CheckboxItem>Checkbox Item</DropdownMenu.CheckboxItem>
    <DropdownMenu.RadioGroup>
      <DropdownMenu.RadioItem value="1">Radio Item</DropdownMenu.RadioItem>
    </DropdownMenu.RadioGroup>
    <DropdownMenu.Sub>
      <DropdownMenu.SubTrigger>Submenu</DropdownMenu.SubTrigger>
      <DropdownMenu.SubContent>
        <DropdownMenu.Item>Submenu Item</DropdownMenu.Item>
      </DropdownMenu.SubContent>
    </DropdownMenu.Sub>
    <DropdownMenu.Separator />
  </DropdownMenu.Content>
</DropdownMenu.Root>

Props

Root

PropTypeDescription
defaultOpenbooleanThe open state when initially rendered. Use for uncontrolled components
openbooleanControlled open state. Must be used with onOpenChange
onOpenChange(open: boolean) => voidEvent handler called when the open state changes
modalbooleanWhen true, interaction outside the menu is blocked. Defaults to true
dir'ltr' | 'rtl'Reading direction for submenus. Inherited from theme if not specified

Trigger

PropTypeDescription
asChildbooleanMerges props with child element. Always true for this component
childrenReactNodeThe trigger element, typically a Button

Content

PropTypeDefaultDescription
size'1' | '2''2'Size of the menu items
variant'solid' | 'soft''solid'Visual style of the menu
colorAccentColorAccent color for highlighted items. Inherits from theme if not set
highContrastbooleanfalseIncreases contrast for better visibility
material'solid' | 'translucent'Background appearance. Inherits from theme if not set
submenuBehavior'cascade' | 'drill-down' | Responsive'cascade'How submenus behave. See Submenu Behavior
virtualizedbooleanfalseEnables virtualization mode. See Virtualization
containerHTMLElementCustom container for the portal
forceMountbooleanForces content to remain in DOM when closed
side'top' | 'right' | 'bottom' | 'left''bottom'Preferred side relative to trigger
sideOffsetnumber4Distance from trigger in pixels
align'start' | 'center' | 'end''start'Alignment relative to trigger
alignOffsetnumber0Offset from alignment position
collisionPaddingnumber10Padding from viewport edges

Item

PropTypeDefaultDescription
colorAccentColorAccent color for the item
shortcutstringKeyboard shortcut displayed on the right
disabledbooleanfalsePrevents interaction when true
onSelect() => voidEvent handler called when the item is selected

CheckboxItem

PropTypeDefaultDescription
colorAccentColorAccent color for the item
shortcutstringKeyboard shortcut displayed on the right
checkedboolean | 'indeterminate'Controlled checked state
onCheckedChange(checked: boolean) => voidEvent handler for checked state changes
disabledbooleanfalsePrevents interaction when true

RadioGroup

PropTypeDescription
valuestringControlled value of the selected item
onValueChange(value: string) => voidEvent handler for value changes

RadioItem

PropTypeDefaultDescription
valuestringUnique value for the radio item (required)
colorAccentColorAccent color for the item
disabledbooleanfalsePrevents interaction when true

Sub

PropTypeDefaultDescription
labelReactNode'Back'Text for the back button in drill-down mode

SubTrigger

PropTypeDescription
disabledbooleanPrevents interaction when true
childrenReactNodeContent of the submenu trigger

SubContent

Accepts the same props as Content.

Variants

Solid

Default variant with an opaque background for standard interfaces.

tsx
<DropdownMenu.Root>
  <DropdownMenu.Trigger>
    <Button variant="soft">Options</Button>
  </DropdownMenu.Trigger>
  <DropdownMenu.Content variant="solid">
    <DropdownMenu.Item>Edit</DropdownMenu.Item>
    <DropdownMenu.Item>Duplicate</DropdownMenu.Item>
  </DropdownMenu.Content>
</DropdownMenu.Root>

Soft

Subtle background with reduced contrast for content-heavy interfaces.

tsx
<DropdownMenu.Root>
  <DropdownMenu.Trigger>
    <Button variant="soft">Options</Button>
  </DropdownMenu.Trigger>
  <DropdownMenu.Content variant="soft">
    <DropdownMenu.Item>Edit</DropdownMenu.Item>
    <DropdownMenu.Item>Duplicate</DropdownMenu.Item>
  </DropdownMenu.Content>
</DropdownMenu.Root>

Sizes

Size 1

Compact size for dense interfaces and toolbars.

tsx
<DropdownMenu.Content size="1">
  <DropdownMenu.Item>Edit</DropdownMenu.Item>
  <DropdownMenu.Item>Duplicate</DropdownMenu.Item>
</DropdownMenu.Content>

Size 2

Standard size for most interfaces.

tsx
<DropdownMenu.Content size="2">
  <DropdownMenu.Item>Edit</DropdownMenu.Item>
  <DropdownMenu.Item>Duplicate</DropdownMenu.Item>
</DropdownMenu.Content>

Colors

Use the color prop on items to communicate intent. Destructive actions should use red or crimson.

tsx
<DropdownMenu.Content>
  <DropdownMenu.Item>Edit</DropdownMenu.Item>
  <DropdownMenu.Item>Duplicate</DropdownMenu.Item>
  <DropdownMenu.Separator />
  <DropdownMenu.Item color="red">Delete</DropdownMenu.Item>
</DropdownMenu.Content>

Material

Solid

Opaque background for standard interfaces.

tsx
<DropdownMenu.Content material="solid">
  <DropdownMenu.Item>Edit</DropdownMenu.Item>
</DropdownMenu.Content>

Translucent

Frosted glass effect for depth over images or dynamic backgrounds. Use highContrast for readability.

tsx
<DropdownMenu.Content material="translucent" highContrast>
  <DropdownMenu.Item>Edit</DropdownMenu.Item>
</DropdownMenu.Content>

Shortcuts

Display keyboard shortcuts using the shortcut prop on items.

tsx
<DropdownMenu.Content>
  <DropdownMenu.Item shortcut="⌘ E">Edit</DropdownMenu.Item>
  <DropdownMenu.Item shortcut="⌘ D">Duplicate</DropdownMenu.Item>
  <DropdownMenu.Item shortcut="⌘ ⌫" color="red">Delete</DropdownMenu.Item>
</DropdownMenu.Content>

Labels

Use Label to add section headings within the menu.

tsx
<DropdownMenu.Content>
  <DropdownMenu.Label>Actions</DropdownMenu.Label>
  <DropdownMenu.Item>Edit</DropdownMenu.Item>
  <DropdownMenu.Item>Duplicate</DropdownMenu.Item>
  <DropdownMenu.Separator />
  <DropdownMenu.Label>Danger Zone</DropdownMenu.Label>
  <DropdownMenu.Item color="red">Delete</DropdownMenu.Item>
</DropdownMenu.Content>

Groups

Use Group to logically group related items.

tsx
<DropdownMenu.Content>
  <DropdownMenu.Group>
    <DropdownMenu.Item>Cut</DropdownMenu.Item>
    <DropdownMenu.Item>Copy</DropdownMenu.Item>
    <DropdownMenu.Item>Paste</DropdownMenu.Item>
  </DropdownMenu.Group>
  <DropdownMenu.Separator />
  <DropdownMenu.Group>
    <DropdownMenu.Item>Select All</DropdownMenu.Item>
  </DropdownMenu.Group>
</DropdownMenu.Content>

Checkbox Items

Use CheckboxItem for toggleable options within the menu.

tsx
function CheckboxExample() {
  const [showToolbar, setShowToolbar] = React.useState(true);
  const [showSidebar, setShowSidebar] = React.useState(false);
 
  return (
    <DropdownMenu.Content>
      <DropdownMenu.Label>View</DropdownMenu.Label>
      <DropdownMenu.CheckboxItem
        checked={showToolbar}
        onCheckedChange={setShowToolbar}
      >
        Show Toolbar
      </DropdownMenu.CheckboxItem>
      <DropdownMenu.CheckboxItem
        checked={showSidebar}
        onCheckedChange={setShowSidebar}
      >
        Show Sidebar
      </DropdownMenu.CheckboxItem>
    </DropdownMenu.Content>
  );
}

Radio Items

Use RadioGroup and RadioItem for mutually exclusive options.

tsx
function RadioExample() {
  const [view, setView] = React.useState('list');
 
  return (
    <DropdownMenu.Content>
      <DropdownMenu.Label>View Mode</DropdownMenu.Label>
      <DropdownMenu.RadioGroup value={view} onValueChange={setView}>
        <DropdownMenu.RadioItem value="list">List</DropdownMenu.RadioItem>
        <DropdownMenu.RadioItem value="grid">Grid</DropdownMenu.RadioItem>
        <DropdownMenu.RadioItem value="board">Board</DropdownMenu.RadioItem>
      </DropdownMenu.RadioGroup>
    </DropdownMenu.Content>
  );
}

Submenus

Use Sub, SubTrigger, and SubContent for nested navigation.

tsx
<DropdownMenu.Content>
  <DropdownMenu.Item>New File</DropdownMenu.Item>
  <DropdownMenu.Sub>
    <DropdownMenu.SubTrigger>Share</DropdownMenu.SubTrigger>
    <DropdownMenu.SubContent>
      <DropdownMenu.Item>Email</DropdownMenu.Item>
      <DropdownMenu.Item>Messages</DropdownMenu.Item>
      <DropdownMenu.Item>Slack</DropdownMenu.Item>
    </DropdownMenu.SubContent>
  </DropdownMenu.Sub>
  <DropdownMenu.Separator />
  <DropdownMenu.Item>Settings</DropdownMenu.Item>
</DropdownMenu.Content>

Submenu Behavior

The submenuBehavior prop controls how submenus appear and interact. There are two modes:

Cascade (Default)

Submenus open as floating panels to the side of the trigger, using portals. This is the standard desktop behavior.

tsx
<DropdownMenu.Content submenuBehavior="cascade">
  <DropdownMenu.Sub>
    <DropdownMenu.SubTrigger>Share</DropdownMenu.SubTrigger>
    <DropdownMenu.SubContent>
      <DropdownMenu.Item>Email</DropdownMenu.Item>
      <DropdownMenu.Item>Messages</DropdownMenu.Item>
    </DropdownMenu.SubContent>
  </DropdownMenu.Sub>
</DropdownMenu.Content>

Drill-Down

Submenus replace the current menu content inline with a back button, similar to mobile navigation patterns. This prevents horizontal overflow on small screens.

tsx
<DropdownMenu.Content submenuBehavior="drill-down">
  <DropdownMenu.Item>Home</DropdownMenu.Item>
  <DropdownMenu.Sub label="Settings">
    <DropdownMenu.SubTrigger>Settings</DropdownMenu.SubTrigger>
    <DropdownMenu.SubContent>
      <DropdownMenu.Item>General</DropdownMenu.Item>
      <DropdownMenu.Item>Privacy</DropdownMenu.Item>
    </DropdownMenu.SubContent>
  </DropdownMenu.Sub>
</DropdownMenu.Content>

The label prop on Sub sets the back button text. It defaults to "Back" if not provided.

Responsive

Use a responsive object to switch between behaviors at different breakpoints. This provides the best experience on both mobile and desktop.

tsx
<DropdownMenu.Content 
  submenuBehavior={{ initial: 'drill-down', md: 'cascade' }}
>
  <DropdownMenu.Item>Dashboard</DropdownMenu.Item>
  <DropdownMenu.Sub label="Account">
    <DropdownMenu.SubTrigger>Account</DropdownMenu.SubTrigger>
    <DropdownMenu.SubContent>
      <DropdownMenu.Item>Profile</DropdownMenu.Item>
      <DropdownMenu.Item>Billing</DropdownMenu.Item>
    </DropdownMenu.SubContent>
  </DropdownMenu.Sub>
</DropdownMenu.Content>

Breakpoint values: initial (default), xs (520px), sm (768px), md (1024px), lg (1280px), xl (1640px).

Trigger Icon

Use TriggerIcon to add a dropdown indicator to custom triggers.

tsx
<DropdownMenu.Trigger>
  <Button variant="soft">
    Options
    <DropdownMenu.TriggerIcon />
  </Button>
</DropdownMenu.Trigger>

Virtualization

For menus with many items (hundreds or thousands), use the virtualized prop with VirtualMenu to render only visible items. This dramatically improves performance by keeping the DOM small.

Simple Usage

Use itemLabel for basic text items:

tsx
import { DropdownMenu, VirtualMenu, Button } from '@kushagradhawan/kookie-ui';
 
const items = Array.from({ length: 1000 }, (_, i) => ({
  id: String(i),
  label: `Item ${i + 1}`,
}));
 
function VirtualizedExample() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        <Button variant="soft">Open Menu</Button>
      </DropdownMenu.Trigger>
      <DropdownMenu.Content virtualized style={{ minWidth: 220, padding: 0 }}>
        <VirtualMenu
          items={items}
          itemLabel={(item) => item.label}
          onSelect={(item) => console.log('Selected:', item)}
          style={{ height: 300 }}
        />
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  );
}

How It Works

  1. Set virtualized on Content to skip the built-in ScrollArea
  2. Use VirtualMenu with your items array
  3. Provide itemLabel (for simple text) or renderItem (for custom rendering)
  4. Set a fixed height on VirtualMenu (required for virtualization)

VirtualMenu Props

PropTypeDefaultDescription
itemsT[]Array of items to render (required)
itemLabelkeyof T | (item: T) => ReactNodeLabel accessor for simple text items
renderItemReact.ComponentType<RenderItemProps<T>>Custom component for rendering items
onSelect(item: T, index: number) => voidCallback when an item is selected
estimatedItemSizenumber | (index: number) => number36Estimated height per item
overscannumber5Extra items to render above/below visible area
styleCSSPropertiesMust include height for virtualization

You must provide either itemLabel or renderItem.

Custom Rendering with renderItem

For custom item layouts, pass a memoized component to renderItem:

tsx
import { VirtualMenu, type VirtualMenuRenderItemProps } from '@kushagradhawan/kookie-ui';
 
type User = { id: string; name: string; email: string; avatar: string };
 
const UserItem = React.memo(function UserItem({
  item,
  style,
  ...props
}: VirtualMenuRenderItemProps<User>) {
  return (
    <VirtualMenu.Item {...props} style={style}>
      <Avatar size="1" src={item.avatar} />
      <span>{item.name}</span>
      <span style={{ color: 'var(--gray-9)' }}>{item.email}</span>
    </VirtualMenu.Item>
  );
});
 
<VirtualMenu
  items={users}
  renderItem={UserItem}
  onSelect={(user) => selectUser(user)}
  style={{ height: 400 }}
/>

Important: The renderItem component must:

  • Be wrapped in React.memo for optimal performance
  • Spread style and other props onto VirtualMenu.Item

RenderItem Props

Props passed to your renderItem component:

PropTypeDescription
itemTThe item data
indexnumberIndex in the items array
isHighlightedbooleanWhether the item is currently highlighted
styleCSSPropertiesPositioning styles (required for virtualization)
idstringUnique ID for accessibility
tabIndexnumberFocus management
data-highlightedbooleanCSS styling hook
data-indexnumberIndex for event delegation
onMouseEnter() => voidHover handler
onClick() => voidSelection handler
onKeyDown(e) => voidKeyboard handler

Variable Height Items

Use a function for estimatedItemSize when items have different heights:

tsx
type Item = { id: string; type: 'header' | 'item'; label: string };
 
const SectionItem = React.memo(function SectionItem({
  item,
  style,
  ...props
}: VirtualMenuRenderItemProps<Item>) {
  const isHeader = item.type === 'header';
  return (
    <VirtualMenu.Item
      {...props}
      style={{ ...style, height: isHeader ? 48 : 36 }}
    >
      <span style={{
        fontWeight: isHeader ? 600 : 400,
        fontSize: isHeader ? 11 : 14,
        textTransform: isHeader ? 'uppercase' : undefined,
      }}>
        {item.label}
      </span>
    </VirtualMenu.Item>
  );
});
 
const items: Item[] = [
  { id: '0', type: 'header', label: 'Section A' },
  { id: '1', type: 'item', label: 'Item 1' },
  { id: '2', type: 'item', label: 'Item 2' },
  { id: '3', type: 'header', label: 'Section B' },
  // ...
];
 
<VirtualMenu
  items={items}
  renderItem={SectionItem}
  estimatedItemSize={(index) => items[index].type === 'header' ? 48 : 36}
  style={{ height: 300 }}
/>

Keyboard Navigation

VirtualMenu supports full keyboard navigation:

KeyAction
Arrow DownMove to next item
Arrow UpMove to previous item
HomeJump to first item
EndJump to last item
Enter / SpaceSelect highlighted item
EscapeClose menu

Controlled

Control the open state for programmatic menu management.

tsx
function ControlledExample() {
  const [open, setOpen] = React.useState(false);
 
  return (
    <DropdownMenu.Root open={open} onOpenChange={setOpen}>
      <DropdownMenu.Trigger>
        <Button variant="soft">Options</Button>
      </DropdownMenu.Trigger>
      <DropdownMenu.Content>
        <DropdownMenu.Item onSelect={() => setOpen(false)}>
          Done
        </DropdownMenu.Item>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  );
}

Accessibility

Dropdown Menu follows the WAI-ARIA Menu Button pattern and includes:

Keyboard Navigation

KeyAction
Space / EnterOpens menu from trigger, selects focused item. In drill-down mode, navigates into submenu or activates back button.
Arrow DownOpens menu from trigger, moves focus down
Arrow UpMoves focus up, wraps from first to last
Arrow RightOpens submenu when focused on SubTrigger (cascade mode)
Arrow LeftCloses submenu, returns focus to SubTrigger (cascade mode)
EscapeCloses menu and returns focus to trigger
Home / EndMoves focus to first/last item
Type characterMoves focus to item starting with that letter

Screen Readers

  • Proper role="menu" and role="menuitem" attributes
  • aria-haspopup on trigger indicates menu availability
  • aria-expanded reflects open state
  • aria-checked for checkbox and radio items
  • aria-disabled for disabled items
  • Focus management returns to trigger on close

Focus Management

  • Focus is trapped within menu when open
  • Focus returns to trigger when menu closes
  • Submenus manage focus independently

Changelog

Added

  • Built on Radix UI Dropdown Menu primitive
  • material prop for solid/translucent backgrounds
  • Integrated ScrollArea for long menus
  • shortcut prop for keyboard shortcut display using Kbd component
  • Theme integration for accent colors and materials
  • Drill-down submenu behavior via submenuBehavior prop for mobile-friendly navigation
  • label prop on Sub for custom back button text in drill-down mode
  • Responsive submenu behavior with breakpoint-based switching between cascade and drill-down modes
  • Drill-down animations with direction-aware slide transitions (slides from right when entering submenu, from left when going back)
  • Respects prefers-reduced-motion for drill-down animations
  • Virtualization support via virtualized prop and VirtualMenu component for rendering large lists efficiently

Deprecated

  • panelBackground prop in favor of material prop
© 2026 Kushagra Dhawan. Licensed under MIT. GitHub.

Theme

Accent color

Gray color

Appearance

Radius

Scaling

Panel background