Skip to content

Virtual lists

Options render only while the menu is open, but then every one of them does. For thousands of options, render them through a virtual list library, which renders only the rows in view. Pick any: you install it, this package doesn’t depend on one.

renderList hands the library the open list’s rows as { count, row, activeIndex } (MultiSelectListProps):

  • count: how many rows there are.
  • row(index): renders one row. Only the rows you ask for get built, so a long list opens at once.
  • activeIndex: the row the keyboard highlights, -1 for none (or while the highlight is in a submenu’s list). Scroll it into view as arrow keys, Home and End move it.

renderList is a render function, not a component, so it can’t use hooks: return a component of yours, renderList={(list) => <VirtualList {...list} />}. Search, picking, the keyboard, limit and customValue work as without it.

VirtualList.tsx
import { useEffect } from 'react'
import MultiSelect from 'react-multiple-select-dropdown-lite'
import type { MultiSelectListProps } from 'react-multiple-select-dropdown-lite'
import 'react-multiple-select-dropdown-lite/dist/index.css'
import { List, useListCallbackRef } from 'react-window'
import type { RowComponentProps } from 'react-window'
const options = Array.from({ length: 100_000 }, (_, i) => ({
label: `Item ${i + 1}`,
value: `item-${i + 1}`
}))
const Row = ({
index,
style,
row
}: RowComponentProps<Pick<MultiSelectListProps, 'row'>>) => (
// top, not react-window's transform: Firefox places a submenu off a transformed row
<div style={{ ...style, transform: 'none', top: index * 36 }}>
{row(index)}
</div>
)
// renders only the rows in view
export function VirtualList({ count, row, activeIndex }: MultiSelectListProps) {
// state, not a ref: the list can scroll once it has mounted
const [list, setList] = useListCallbackRef(null)
// keeps the keyboard highlight in view
useEffect(() => {
activeIndex >= 0 && list?.scrollToRow({ index: activeIndex })
}, [list, activeIndex])
return (
<List
listRef={setList}
// no role="list" between the listbox and its options
role='none'
// no tab stop: Firefox makes a scroller one
tabIndex={-1}
rowComponent={Row}
rowCount={count}
rowHeight={36}
rowProps={{ row }}
/>
)
}
export default function VirtualListDemo() {
return (
<MultiSelect
options={options}
placeholder='100,000 items'
renderList={(list) => <VirtualList {...list} />}
/>
)
}

It measures each row, so rows can differ in height. Here with every kind of row, groups and submenus in each other:

TanstackVirtual.tsx
import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useRef } from 'react'
import MultiSelect from 'react-multiple-select-dropdown-lite'
import type { MultiSelectListProps } from 'react-multiple-select-dropdown-lite'
import 'react-multiple-select-dropdown-lite/dist/index.css'
// renders only the rows in view, measured: group titles are shorter than options
export function TanstackList({
count,
row,
activeIndex
}: MultiSelectListProps) {
const ref = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count,
getScrollElement: () => ref.current,
estimateSize: () => 36
})
// keeps the keyboard highlight in view
useEffect(() => {
activeIndex >= 0 && virtualizer.scrollToIndex(activeIndex)
}, [activeIndex])
return (
// no tab stop: Firefox makes a scroller one
<div ref={ref} tabIndex={-1} style={{ overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
width: '100%',
// top, not a transform: Firefox places a submenu off a transformed row
top: item.start
}}
>
{row(item.index)}
</div>
))}
</div>
</div>
)
}
const items = (name: string, count: number) =>
Array.from({ length: count }, (_, i) => ({
label: `${name} ${i + 1}`,
value: `${name.toLowerCase()}-${i + 1}`
}))
// every kind of row: a disabled option, a group in a group, collapsible and collapsed groups, a submenu in a
// group, a group in a submenu, a submenu in a submenu, an empty group and long lists
export const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Dragon fruit (out of season)', value: 'dragon', disabled: true },
{
type: 'group',
title: 'Berries',
childs: [
{ label: 'Blueberry', value: 'blueberry' },
{
type: 'group',
title: 'Wild',
childs: [{ label: 'Cloudberry', value: 'cloudberry' }]
}
]
},
{
type: 'group',
title: 'Citrus',
collapsible: true,
childs: [
{ label: 'Lemon', value: 'lemon' },
{
type: 'submenu',
title: 'More citrus',
childs: [
{ label: 'Grapefruit', value: 'grapefruit' },
{ label: 'Yuzu', value: 'yuzu' }
]
}
]
},
{
type: 'submenu',
title: 'Vegetables',
childs: [
{ label: 'Carrot', value: 'carrot' },
{
type: 'group',
title: 'Leafy greens',
collapsible: true,
collapsed: true,
childs: [
{ label: 'Kale', value: 'kale' },
{ label: 'Spinach', value: 'spinach' }
]
},
{
type: 'submenu',
title: '1,000 more',
childs: items('Vegetable', 1000)
}
]
},
{
type: 'group',
title: 'Exotic',
childs: [],
emptyDataLabel: 'Out of season'
},
{
type: 'group',
title: '10,000 items',
collapsible: true,
collapsed: true,
childs: items('Item', 10_000)
}
]
export default function TanstackVirtual() {
return (
<MultiSelect
options={options}
placeholder='Every kind of row'
renderList={(list) => <TanstackList {...list} />}
/>
)
}

Collapsible groups and submenus work as without renderList. A collapsible group’s title is a row: while the group is closed its rows drop out of the list, so count shrinks, and they come back as it opens. An open submenu is a list of its own, its rows rendered through renderList too, in its popover beside its title row. A browser without CSS anchor positioning shows it in the flow, under its title, inside that row: a list with a fixed row height, as react-window’s below, clips it there and draws the next rows over it, so there use one that measures its rows, as TanStack Virtual does. The stylesheet makes the popover as wide as --msl-input-width, since rows placed by a virtual list have no width of their own. If that is a percentage, such as 100%, give .msl-submenu a length of its own (.msl-submenu { width: 300px }): in the top layer a percentage sizes the popover from the room beside its title, not from the dropdown. Here 100,000 options in 100 collapsed groups, each 100 options and a submenu of 900 more, through the react-window list above:

VirtualGroups.tsx
import MultiSelect from 'react-multiple-select-dropdown-lite'
import 'react-multiple-select-dropdown-lite/dist/index.css'
// the react-window list above
import { VirtualList } from './VirtualList'
// 100 collapsed groups, each 100 options and a submenu of 900 more
const options = Array.from({ length: 100 }, (_, g) => ({
type: 'group',
title: `Group ${g + 1}`,
collapsible: true,
collapsed: true,
childs: [
{
type: 'submenu',
title: `More in group ${g + 1}`,
childs: Array.from({ length: 900 }, (_, i) => ({
label: `Item ${g + 1}.${i + 101}`,
value: `${g + 1}.${i + 101}`
}))
},
...Array.from({ length: 100 }, (_, i) => ({
label: `Item ${g + 1}.${i + 1}`,
value: `${g + 1}.${i + 1}`
}))
]
}))
export default function VirtualGroups() {
return (
<MultiSelect
options={options}
placeholder='100,000 items in groups'
renderList={(list) => <VirtualList {...list} />}
/>
)
}

Animations work as without renderList, except that a collapsible group’s rows come and go at once: there is no group body (msl-grp-body) to slide open and shut. A submenu fades and slides as before. In Firefox, though, a submenu placed by a fallback (on the other side, or over its title) that resizes while its enter animation runs, as TanStack Virtual’s does as it measures the rows, holds the frame the animation was at until it ends, then shows at once. That is a Firefox bug. To keep the fade there, animate the list (.msl-submenu > *) rather than the submenu.

Both recipes above run in the test suite. Another library works the same way, given:

  • A scroller that fits the menu. The listbox (msl-options) gets data-custom, and the stylesheet makes it a flex column, so a scroller in it shrinks to the menu’s height: --msl-menu-max-height, or the room the viewport leaves. A library that needs a height of its own can take the rows’ total, count * 36: the menu caps it.
  • Rows placed with top, not transform. Firefox places a submenu by its title as if the transforms around the title were not there, as the CSS anchor positioning spec first said, so a submenu opens away from a row moved by a transform. react-window moves its rows with one: both recipes place rows with top.
  • No tab stop. Give the scroller tabIndex={-1}, as the listbox has. Firefox makes a scroller a tab stop, and Tab would land in it instead of moving on. react-virtuoso’s scroller is one by default.
  • No roles in between. Nothing between the listbox and its options may have a role: pass role='none' to a library that sets one (react-window’s List is a role="list"), and leave out any ARIA props it offers for rows: the options carry their own aria-posinset and aria-setsize.
  • Rows in boxes of their own. The default stylesheet gives options margins. Rows positioned absolutely keep them, as in both recipes. A library that stacks rows in the flow (react-virtuoso) measures them short as the margins collapse: put each row in a <div style={{ display: 'flow-root' }}>.
  • A scroll it doesn’t drop. react-virtuoso can drop a scroll called while it updates: call it in requestAnimationFrame.

Rows are options, the titles of groups and submenus, and an empty group’s label, in order, groups flattened. An open submenu’s rows are not in the list it is in, but a list of their own. A search lists matching options only, flat. Group titles are shorter than options: let the library measure rows, or give titles the options’ height.

Each row comes in a display: contents box: when the library unmounts a focused row (a click focuses one) as it scrolls, focus goes to the listbox, where the keys still work. A row in a group comes in a msl-grp box per group around it (classNames.group), so it indents as in the group. There is no role="group" around a group’s rows, so screen readers don’t announce the group’s name.

Without the stylesheet, make the listbox and submenus flex columns yourself, classNames={{ menu: 'data-custom:flex data-custom:flex-col', submenu: 'data-custom:flex data-custom:flex-col data-custom:w-75' }} with Tailwind, and see the open menu for its height.