Virtual Menu

A virtual menu renders a list of hundreds or thousands of menu items by drawing only the rows on screen.

A virtual menu draws only the rows that fit in its height, plus a few above and below, so a list of thousands of items opens as fast as a list of ten. It goes inside a Dropdown Menu or a Context Menu, or stands alone. For a long list that people search rather than scroll, use a Combobox.

Playground

Usage

import { Button, DropdownMenu, VirtualMenu } from '@kushagradhawan/kookie-ui';
 
export function BranchPicker({ branches, onPick }: { branches: { id: string; name: string }[]; onPick: (id: string) => void }) {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        <Button>Switch branch<DropdownMenu.TriggerIcon /></Button>
      </DropdownMenu.Trigger>
      <DropdownMenu.Content virtualized>
        <VirtualMenu
          items={branches}
          itemLabel="name"
          aria-label="Branches"
          onSelect={(branch) => onPick(branch.id)}
          style={{ height: 320 }}
        />
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  );
}

Anatomy

<VirtualMenu items={items} renderItem={Row} />
 
const Row = React.memo(({ item, ...props }) => (
  <VirtualMenu.Item {...props}>{item.name}</VirtualMenu.Item>
));
  • VirtualMenu: The scrolling list. It measures the visible area and draws the rows inside it.
  • VirtualMenu.Item: One row, for a custom layout through renderItem.

Inside a menu, import VirtualMenu and place it in the menu's Content. Menus have no Virtual part.

Best practices

Use it only for long lists. Below about a hundred items, ordinary menu items are simpler and keep every keyboard feature of the parent menu. Reach for a virtual menu when the list is long enough to slow the menu down.

Set virtualized on the parent Content. Without it, the menu wraps the list in its own scroll area, and the list can't measure the space it draws into.

Give the list a fixed height. The list draws only what fits in its height, so it needs one. Set height in style, or through a class.

Keep every row the same height. The list places each row from the heights it is given, and one height needs no setup. When rows must differ, pass a function to estimatedItemSize.

Items

Pass itemLabel for rows of plain text. It accepts the name of a property, such as "name", or a function that returns the label. Pass renderItem for rows with icons, avatars or a second line of text. One of the two is required, and TypeScript reports a list that has neither.

A renderItem component receives the item, its index and the props that position and wire up the row. Spread those props onto VirtualMenu.Item, and wrap the component in React.memo at module scope, so a scroll redraws only the rows that move.

import { Avatar, Flex, Text, VirtualMenu, type VirtualMenuRenderItemProps } from '@kushagradhawan/kookie-ui';
 
const PersonRow = React.memo(function PersonRow({ item, isHighlighted, index, ...props }: VirtualMenuRenderItemProps<Person>) {
  return (
    <VirtualMenu.Item {...props}>
      <Avatar size="1" src={item.avatar} fallback={item.initials} />
      <Text size="2">{item.name}</Text>
    </VirtualMenu.Item>
  );
});

Size

Row height follows the size of the parent menu, so a virtual list in a size 1 DropdownMenu.Content uses size 1 rows. Inside a menu, the list reads the row height the CSS draws, so rows stay in step with the theme's scale. Outside a menu, such as in a Popover, the list draws the same rows and highlight as a menu at its own size, which is '2' unless you set it. Set estimatedItemSize only for rows taller than a menu row.

Examples

Assigning from a large team

Use renderItem to show an avatar beside each name, so people can tell two people with similar names apart.

<DropdownMenu.Content virtualized>
  <VirtualMenu items={members} renderItem={PersonRow} aria-label="Team members" onSelect={assign} style={{ height: 360 }} />
</DropdownMenu.Content>

Rows of different heights

Pass a function to estimatedItemSize that returns the height of each row in pixels. The list gives each row that height through the style it passes to the row.

<VirtualMenu
  items={rows}
  renderItem={SectionRow}
  estimatedItemSize={(index) => (rows[index].type === 'heading' ? 40 : 30)}
  style={{ height: 320 }}
/>

Props

VirtualMenu

PropTypeDefaultDescription
itemsT[]The items to list. Required
itemLabelkeyof T | (item: T) => ReactNodeThe label of each row, for plain text rows
renderItemComponentType<VirtualMenuRenderItemProps<T>>A component that draws each row. Wrap it in React.memo
onSelect(item: T, index: number) => voidCalled when someone chooses a row. Inside a menu, the menu then closes
size'1' | '2' | '3' | '4'Parent menu's size, else '2'The row height step
estimatedItemSizenumber | (index: number) => numberThe row height for sizeThe height of each row in pixels
overscannumber5Rows drawn above and below the visible area
aria-labelstringThe name of the list
aria-labelledby, id, data-*Passed to the list element, with the other attributes of the HTML div element
classNamestringA class for the list
styleCSSPropertiesStyles for the list. Set height here or in the class
refRef<HTMLDivElement>The list element, for moving focus to it

VirtualMenu.Item

VirtualMenu.Item accepts every attribute of the HTML div element. Pass it the props from renderItem. It redraws when any of its props change, other than a new style object with the same height and position.

Render item props

PropTypeDescription
itemTThe item for this row
indexnumberThe position of the item in items
isHighlightedbooleanWhether the row is highlighted
id, style, tabIndexThe id, position and tab stop of the row. Pass them through
aria-setsize, aria-posinsetnumberThe length of the whole list and the row's place in it, since only some rows are drawn. Pass them through
data-highlighted, data-indexAttributes for the highlight style and the event handlers. Pass them through
onClick, onKeyDown, onMouseEnter, onMouseLeaveThe handlers that choose and highlight the row. Pass them through

Accessibility

KeyAction
Arrow Down or Arrow UpMoves the highlight, and wraps at the ends
Home or EndMoves the highlight to the first or last row
Enter or SpaceChooses the highlighted row
EscapeTakes focus off the list. Inside a menu, it closes the menu and returns focus to the trigger
TabOn its own, moves focus to the next element on the page. Inside a menu, it does nothing, as in any menu
  • Roles: Each row has the menuitem role. On its own, the list has the menu role. Inside a Dropdown Menu or a Context Menu, it has the group role, because the parent is the menu.
  • Name: Set aria-label so a screen reader can name the list.
  • Highlight: The list keeps focus and points aria-activedescendant at the highlighted row, so a screen reader announces each row as the highlight moves. Moving the pointer off a row clears its highlight.
  • Position: Each row sets aria-setsize and aria-posinset, so a screen reader announces the length of the whole list, not only the rows drawn.
  • Letters: Inside a menu, typing a letter while the list has focus does not move focus to a menu item outside the list.
  • Focus: The list is one tab stop. Inside a menu, the list takes focus when the menu opens and highlights the first row, so the arrow keys reach the rows straight away. Choosing a row closes the menu and returns focus to its trigger.