Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

LayoutReact

Virtualizer

A scroller that renders only the rows near the scroll position, so a list of 10,000 items keeps a few dozen in the DOM.

Rendered by React 19.3.1

import * as React from 'react'
import { Virtualizer } from '@hamur/react/virtualizer'

const levels = ['info', 'warn', 'info', 'error']
const messages = [
  'Build started',
  'Restored 214 files from the cache of the previous run',
  'Tests passed',
  'The bundle grew by 1.2 kB; the largest chunk is still the date picker, which pulls in its calendar grid and the locale tables',
  'Deployed the preview',
  'Retrying the upload after a timeout from the storage service, attempt 2 of 3',
  'Lint found no problems',
]
const entries = Array.from({ length: 10000 }, (_, index) => index + 1)

export default function VirtualizerExample() {
  const [range, setRange] = React.useState({ start: 0, end: 15 })
  return (
    <div className="stack">
      <Virtualizer
        className="virtualizer-list"
        style={{ height: 280 }}
        role="list"
        aria-label="Build log"
        tabIndex={0}
        items={entries}
        estimatedItemHeight={52}
        onRangeChange={setRange}
      >
        {(entry: number, _index: number, props) => (
          <div {...props} role="listitem" className="virtualizer-row">
            <span className="virtualizer-row-meta">
              #{entry} · {levels[entry % levels.length]}
            </span>
            <span>{messages[entry % messages.length]}</span>
            {entry % 3 === 0 ? (
              <span className="virtualizer-row-meta">Took {(entry % 90) + 10} ms</span>
            ) : null}
          </div>
        )}
      </Virtualizer>
      <p className="muted">
        Rendered rows {range.start + 1} to {range.end} of 10,000
      </p>
    </div>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<Virtualizer />

Examples

Listbox

A Listbox of 5,000 options; End, Home, Page Down and typeahead reach options that are not mounted.

VirtualizerListbox.tsx
import * as React from 'react'
import { Listbox } from '@hamur/react/listbox'
import { Virtualizer } from '@hamur/react/virtualizer'

const words = ['Amber', 'Cedar', 'Fern', 'Jade', 'Moss', 'Pearl', 'Sage', 'Zinc']
const options = Array.from(
  { length: 5000 },
  (_, index) =>
    `${words[Math.floor((index * words.length) / 5000)]} ${String(index + 1).padStart(4, '0')}`,
)

export default function VirtualizerListboxExample() {
  const [value, setValue] = React.useState<string | null>(null)
  return (
    <div className="field">
      <div id="virtualizer-listbox-label" className="listbox-label">
        5,000 colors
      </div>
      <Listbox.Root
        className="listbox virtualizer-listbox"
        aria-labelledby="virtualizer-listbox-label"
        items={options}
        value={value}
        onValueChange={(next) => setValue(next as string | null)}
      >
        <Virtualizer
          className="virtualizer-listbox-scroller"
          style={{ height: 260 }}
          itemHeight={32}
        >
          {(option: string) => (
            <Listbox.Item value={option} className="listbox-item">
              <Listbox.ItemIndicator className="listbox-item-indicator">✓</Listbox.ItemIndicator>
              <Listbox.ItemText className="listbox-item-text">{option}</Listbox.ItemText>
            </Listbox.Item>
          )}
        </Virtualizer>
      </Listbox.Root>
      <p className="muted">Selected: {value ?? 'none'}</p>
    </div>
  )
}

Data Table

A Data Table grid of 1,000 rows and 40 columns with layout="table" and columnCount; cells are placed by --column-offset.

VirtualizerDataTable.tsx
import * as React from 'react'
import { DataTable } from '@hamur/react/data-table'
import { Virtualizer } from '@hamur/react/virtualizer'

const COLUMNS = 40
const columns = Array.from({ length: COLUMNS }, (_, index) => index)
const rows = Array.from({ length: 1000 }, (_, index) => index + 1)
const reading = (row: number, column: number) => ((row * 31 + column * 17) % 900) + 100

export default function VirtualizerDataTableExample() {
  const [range, setRange] = React.useState({ start: 0, end: 15 })
  return (
    <div className="stack">
      <div className="virtualizer-table-scroller" style={{ height: 288 }}>
        <DataTable.Root
          className="data-table virtualizer-table"
          aria-label="Sensor readings"
          grid
          rows={rows}
          style={{ width: COLUMNS * 96 }}
        >
          <DataTable.Header>
            <DataTable.Row>
              {columns.map((column) => (
                <DataTable.ColumnHeader
                  key={column}
                  columnIndex={column}
                  className="data-table-head virtualizer-table-head"
                >
                  {column === 0 ? 'Sensor' : `Hour ${column}`}
                </DataTable.ColumnHeader>
              ))}
            </DataTable.Row>
          </DataTable.Header>
          <Virtualizer
            layout="table"
            scrollElement={null}
            itemHeight={36}
            columnCount={COLUMNS}
            columnWidth={96}
            onRangeChange={setRange}
          >
            {(row: number) => (
              <DataTable.Row value={row} className="data-table-row">
                {columns.map((column) =>
                  column === 0 ? (
                    <DataTable.RowHeader
                      key={column}
                      columnIndex={column}
                      className="data-table-cell virtualizer-table-cell"
                    >
                      Sensor {row}
                    </DataTable.RowHeader>
                  ) : (
                    <DataTable.Cell
                      key={column}
                      columnIndex={column}
                      className="data-table-cell data-table-number virtualizer-table-cell"
                    >
                      {reading(row, column)}
                    </DataTable.Cell>
                  ),
                )}
              </DataTable.Row>
            )}
          </Virtualizer>
        </DataTable.Root>
      </div>
      <p className="muted">
        Rendered rows {range.start + 1} to {range.end} of 1,000
      </p>
    </div>
  )
}

Chat

A role="log" chat with anchor="end"; onStartReached prepends 30 older messages and the first visible message stays in place.

VirtualizerChat.tsx
import * as React from 'react'
import { Virtualizer } from '@hamur/react/virtualizer'

const TOTAL = 1000
const PAGE = 30
const people = ['Ada', 'Grace']
const lines = [
  'Did the nightly build pass?',
  'Yes, all green.',
  'The flaky upload test failed once, then passed on the retry. I opened an issue so we stop ignoring it.',
  'Thanks!',
  'Can you look at the preview before the review this afternoon?',
]
const range = (first: number, last: number) =>
  Array.from({ length: last - first + 1 }, (_, index) => first + index)

export default function VirtualizerChatExample() {
  const [messages, setMessages] = React.useState(() => range(TOTAL - PAGE + 1, TOTAL))
  const loadOlder = () =>
    setMessages((current) =>
      current[0] > 1
        ? [...range(Math.max(1, current[0] - PAGE), current[0] - 1), ...current]
        : current,
    )
  return (
    <div className="stack">
      <div role="log" aria-label="Messages" className="virtualizer-chat-log">
        <Virtualizer
          className="virtualizer-chat"
          style={{ height: 300 }}
          anchor="end"
          items={messages}
          itemHeight={68}
          onStartReached={loadOlder}
        >
          {(id: number, _index: number, props) => (
            <div {...props} className="virtualizer-message" data-author={people[id % 2]}>
              <span className="virtualizer-row-meta">
                {people[id % 2]} · #{id}
              </span>
              <span>{lines[id % lines.length]}</span>
            </div>
          )}
        </Virtualizer>
      </div>
      <p className="muted">
        Loaded {messages.length} of {TOTAL.toLocaleString('en-US')} messages
      </p>
    </div>
  )
}

Scroll To Index

A row number and an alignment; the button scrolls through actionsRef to rows that were never measured.

VirtualizerScrollToIndex.tsx
import * as React from 'react'
import { Virtualizer, type VirtualizerActions } from '@hamur/react/virtualizer'

type Align = 'auto' | 'start' | 'center' | 'end'

const COUNT = 10000
const rows = Array.from({ length: COUNT }, (_, index) => index)
const notes = ['', 'A second line of detail for this row.', '']

export default function VirtualizerScrollToIndexExample() {
  const actionsRef = React.useRef<VirtualizerActions | null>(null)
  const [index, setIndex] = React.useState(5000)
  const [align, setAlign] = React.useState<Align>('start')
  const [range, setRange] = React.useState({ start: 0, end: 15 })
  return (
    <div className="stack">
      <div className="virtualizer-controls">
        <label className="field">
          Row
          <input
            className="input"
            type="number"
            min={0}
            max={COUNT - 1}
            value={index}
            onChange={(event) =>
              setIndex(Math.min(COUNT - 1, Math.max(0, Number(event.target.value) || 0)))
            }
          />
        </label>
        <label className="field">
          Align
          <select
            className="input"
            value={align}
            onChange={(event) => setAlign(event.target.value as Align)}
          >
            <option value="auto">auto</option>
            <option value="start">start</option>
            <option value="center">center</option>
            <option value="end">end</option>
          </select>
        </label>
        <button
          className="button"
          type="button"
          onClick={() => actionsRef.current?.scrollToIndex(index, { align })}
        >
          Scroll
        </button>
      </div>
      <Virtualizer
        className="virtualizer-list"
        style={{ height: 280 }}
        role="list"
        aria-label="Rows"
        tabIndex={0}
        items={rows}
        estimatedItemHeight={40}
        actionsRef={actionsRef}
        onRangeChange={setRange}
      >
        {(row: number, _index: number, props) => (
          <div {...props} role="listitem" className="virtualizer-row" data-row={row}>
            <span>Row {row}</span>
            {notes[row % notes.length] ? (
              <span className="virtualizer-row-meta">{notes[row % notes.length]}</span>
            ) : null}
          </div>
        )}
      </Virtualizer>
      <p className="muted">
        Rendered rows {range.start} to {range.end - 1}
      </p>
    </div>
  )
}

API reference

35 props across 1 parts, in React syntax. className, style, render are listed once, under Shared props.

VirtualizerVirtualizer

Renders only the rows of a long collection that are near the scroll position, keeping the focused, active and kept rows mounted. Inside Listbox, Combobox, Autocomplete, Select, Tree, Tree Select, Cascader, Grid List or DataTable it windows that component's items. (a <tbody> in the table layout). Not in upstream's release (mui/base-ui#5466). Renders a <div>.

Virtualizer props
PropTypeDefault
itemsThe items, flat or as groups of { items }.readonly Item[] | readonly VirtualizerGroup<Item>[]—
getItemKeyReturns a stable key for an item.((item: Item, index: number) => string | number)—
childrenrequiredRenders the row of an item.(item: Item, index: number, props: VirtualizerItemProps) => React.ReactNode—
itemHeightThe height of every row, in px; rows are then not measured.number—
itemWidthThe width of every item of a horizontal list, in px; items are then not measured.number—
estimatedItemHeightThe height of a row before it is measured: a number, refined by the average of the measuredVirtualizerSizeEstimate32
estimatedItemWidthThe width of an item of a horizontal list before it is measured.VirtualizerSizeEstimate—
orientationThe scroll axis.VirtualizerOrientation'vertical'
layoutlist wraps each row; table renders <tr> rows in a <tbody>; grid renders rows ofVirtualizerLayout'list'
columnsItems per row in the grid layout.number—
columnCountThe number of columns, which windows the columns of the table and grid layouts.number—
columnWidthThe width of every column, in px, or a function of the column index.VirtualizerSizeEstimate—
estimatedColumnWidthThe width of a column, in px, without columnWidth.VirtualizerSizeEstimate120
columnOverscanPxThe width rendered beyond the visible columns, in px.number—
overscanPxThe size rendered beyond the viewport, in px.number150
activeIndexThe active item, always mounted.number | { index: number; scroll?: boolean | undefined; align?: VirtualizerAlign | undefined; paddingStart?: number | undefined; paddingEnd?: number | undefined; }—
keepMountedIndices kept mounted outside the window, off-screen and exposed.readonly number[]—
totalItemsThe size of the whole set for aria-setsize; -1 when unknown.number—
itemAriaThe ARIA each row carries: set (aria-posinset, aria-setsize), rowVirtualizerItemAria—
onEndReachedCalled once each time the end of the list comes within endReachedThreshold.(() => void)—
endReachedThresholdThe distance from the end, in px, at which onEndReached is called.number0
onStartReachedCalled once each time the start of the list comes within startReachedThreshold.(() => void)—
startReachedThresholdThe distance from the start, in px, at which onStartReached is called.number0
anchorend starts at the end, follows appended items while at the end, and keeps the first"start" | "end"'start'
scrollElementThe element that scrolls: the Virtualizer itself, the window, an element, or null for theVirtualizerScrollElement'self'
initialIndexThe item scrolled to on mount, and rendered first on the server.number—
initialItemCountThe number of rows rendered on the server and before the first measurement.number15
initialSnapshotA snapshot from actionsRef.current.getSnapshot(), restored on mount.VirtualizerSnapshot—
onRangeChangeCalled with the rendered range (end exclusive) when it changed and the scroll settled.((range: VirtualizerRange) => void)—
renderGroupHeaderRenders the header of a group.((group: VirtualizerGroup<Item>, groupIndex: number, props: { ref?: ((element: Element | null) => void) | undefined; hidden?: boolean | undefined; id: string; 'aria-hidden': true; }) => React.ReactNode)—
getGroupKeyReturns a stable key for a group.((group: VirtualizerGroup<Item>, groupIndex: number) => string | number)—
estimatedGroupHeaderHeightThe height of a group header before it is measured.VirtualizerSizeEstimate—
trailingContent after the last row, such as a loading indicator.React.ReactNode—
enabledWhether rows are windowed.booleantrue
actionsRefA ref to imperative actions.React.Ref<VirtualizerActions>—
Virtualizer data attributes
Data attributeDescription
data-emptyPresent when the collection has no items.
data-orientationIndicates the scroll axis.
Virtualizer CSS variables
CSS variableDescription
--total-sizeThe size of every row along the scroll axis, in pixels.
--total-column-sizeThe width of every column, in pixels, with column windowing.
--column-offsetSet on a row's props.style: the start offset of its first rendered column, in pixels.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
HomeEndIn a host, move to the first or last item, which is mounted and scrolled to firstVirtualizer
PageDownPageUpIn a host, move 10 items, past the rows that are mountedVirtualizer
In a host with typeahead, moves to the next item whose text starts with it, mounted or notVirtualizer
ArrowDownArrowUpIn a Grid List with columns, move by rows across rows that are not mountedVirtualizer
TabFrom a focused row scrolled out of view, moves to the control after the listVirtualizer
ShiftTabFrom a focused row scrolled out of view, moves to the control before the listVirtualizer

Accessibility

What the parts set
PartSetsSo that
Virtualizerrole="presentation" on the spacer, the window and each row wrapper, and no role, tabindex or aria-busy on itselfthe list's own role owns its rows, and the app or the host names the pattern
Virtualizeraria-setsize and aria-posinset in each row's props with itemAria set, the default of a list; aria-rowindex with row, the default of the table and grid layoutsa row is announced at its place in the whole collection (totalItems), not in the rendered slice
Virtualizerthe focused row, the activeIndex row and the keepMounted rows stay mounted outside the window, off-screen and in index order, never hidden, aria-hidden or inertfocus and aria-activedescendant never point at a removed element, and the DOM order is the index order
Virtualizerrole="group" with aria-labelledby around each group's rows, and aria-hidden on the header it namesa grouped row is announced with its group, and the header is not read twice

Focus order

  1. The rows are the tab stops when they are focusable; the host's roving focus or the app's tabindex decides which one. A list of plain content makes the scroller the tab stop.
  2. A focused row that scrolls out of view stays mounted off-screen, so Tab and Shift+Tab leave the list from it to the controls after and before the list.
  3. In a host, the keys move focus or the highlight over the whole collection; the target row is mounted and scrolled into view before it takes focus.