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
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | The items to list. Required | |
itemLabel | keyof T | (item: T) => ReactNode | The label of each row, for plain text rows | |
renderItem | ComponentType<VirtualMenuRenderItemProps<T>> | A component that draws each row. Wrap it in React.memo | |
onSelect | (item: T, index: number) => void | Called 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 |
estimatedItemSize | number | (index: number) => number | The row height for size | The height of each row in pixels |
overscan | number | 5 | Rows drawn above and below the visible area |
aria-label | string | The name of the list | |
aria-labelledby, id, data-* | Passed to the list element, with the other attributes of the HTML div element | ||
className | string | A class for the list | |
style | CSSProperties | Styles for the list. Set height here or in the class | |
ref | Ref<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
| Prop | Type | Description |
|---|---|---|
item | T | The item for this row |
index | number | The position of the item in items |
isHighlighted | boolean | Whether the row is highlighted |
id, style, tabIndex | The id, position and tab stop of the row. Pass them through | |
aria-setsize, aria-posinset | number | The length of the whole list and the row's place in it, since only some rows are drawn. Pass them through |
data-highlighted, data-index | Attributes for the highlight style and the event handlers. Pass them through | |
onClick, onKeyDown, onMouseEnter, onMouseLeave | The handlers that choose and highlight the row. Pass them through |
Accessibility
| Key | Action |
|---|---|
| Arrow Down or Arrow Up | Moves the highlight, and wraps at the ends |
| Home or End | Moves the highlight to the first or last row |
| Enter or Space | Chooses the highlighted row |
| Escape | Takes focus off the list. Inside a menu, it closes the menu and returns focus to the trigger |
| Tab | On 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
menuitemrole. On its own, the list has themenurole. Inside a Dropdown Menu or a Context Menu, it has thegrouprole, because the parent is the menu. - Name: Set
aria-labelso a screen reader can name the list. - Highlight: The list keeps focus and points
aria-activedescendantat 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-setsizeandaria-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.